From bfda089f4e05c1ceb37234efb636665d01702128 Mon Sep 17 00:00:00 2001 From: rckrdmrd Date: Sun, 4 Jan 2026 07:05:07 -0600 Subject: [PATCH] refactor: Configure subrepositorios for apps --- .gitignore | 43 + .gitmodules | 24 + apps/backend/.env.example | 159 - apps/backend/Dockerfile | 73 - .../WEBSOCKET_IMPLEMENTATION_REPORT.md | 563 - apps/backend/WEBSOCKET_TESTING.md | 648 - apps/backend/eslint.config.js | 29 - apps/backend/jest.config.ts | 37 - apps/backend/package-lock.json | 11171 ---------------- apps/backend/package.json | 90 - apps/backend/service.descriptor.yml | 54 - .../src/__tests__/jest-migration.test.ts | 35 - .../src/__tests__/mocks/database.mock.ts | 101 - .../backend/src/__tests__/mocks/email.mock.ts | 79 - .../backend/src/__tests__/mocks/redis.mock.ts | 97 - apps/backend/src/__tests__/setup.ts | 115 - apps/backend/src/config/index.ts | 119 - apps/backend/src/config/swagger.config.ts | 175 - .../src/core/filters/http-exception.filter.ts | 172 - apps/backend/src/core/filters/index.ts | 5 - apps/backend/src/core/guards/auth.guard.ts | 237 - apps/backend/src/core/guards/index.ts | 5 - apps/backend/src/core/interceptors/index.ts | 5 - .../transform-response.interceptor.ts | 108 - .../src/core/middleware/auth.middleware.ts | 206 - .../src/core/middleware/error-handler.ts | 77 - apps/backend/src/core/middleware/not-found.ts | 16 - .../src/core/middleware/rate-limiter.ts | 51 - apps/backend/src/core/websocket/index.ts | 8 - .../core/websocket/trading-stream.service.ts | 825 -- .../src/core/websocket/websocket.server.ts | 418 - apps/backend/src/docs/openapi.yaml | 172 - apps/backend/src/index.ts | 196 - .../backend/src/modules/admin/admin.routes.ts | 431 - .../src/modules/agents/agents.routes.ts | 129 - .../agents/controllers/agents.controller.ts | 504 - .../modules/agents/services/agents.service.ts | 230 - apps/backend/src/modules/auth/auth.routes.ts | 305 - .../auth/controllers/auth.controller.ts | 570 - .../auth/controllers/email-auth.controller.ts | 168 - .../src/modules/auth/controllers/index.ts | 57 - .../auth/controllers/oauth.controller.ts | 248 - .../auth/controllers/phone-auth.controller.ts | 71 - .../auth/controllers/token.controller.ts | 162 - .../auth/controllers/two-factor.controller.ts | 124 - .../modules/auth/dto/change-password.dto.ts | 41 - apps/backend/src/modules/auth/dto/index.ts | 17 - .../backend/src/modules/auth/dto/login.dto.ts | 29 - .../backend/src/modules/auth/dto/oauth.dto.ts | 36 - .../src/modules/auth/dto/refresh-token.dto.ts | 11 - .../src/modules/auth/dto/register.dto.ts | 38 - .../services/__tests__/email.service.spec.ts | 497 - .../services/__tests__/token.service.spec.ts | 489 - .../modules/auth/services/email.service.ts | 583 - .../modules/auth/services/oauth.service.ts | 624 - .../modules/auth/services/phone.service.ts | 435 - .../modules/auth/services/token.service.ts | 211 - .../modules/auth/services/twofa.service.ts | 293 - .../__tests__/oauth-state.store.spec.ts | 409 - .../modules/auth/stores/oauth-state.store.ts | 239 - .../src/modules/auth/types/auth.types.ts | 217 - .../auth/validators/auth.validators.ts | 159 - .../controllers/education.controller.ts | 675 - .../src/modules/education/education.routes.ts | 182 - .../education/services/course.service.ts | 568 - .../education/services/enrollment.service.ts | 420 - .../education/types/education.types.ts | 401 - .../controllers/investment.controller.ts | 530 - .../modules/investment/investment.routes.ts | 117 - .../__tests__/account.service.spec.ts | 547 - .../__tests__/product.service.spec.ts | 378 - .../__tests__/transaction.service.spec.ts | 606 - .../investment/services/account.service.ts | 344 - .../investment/services/product.service.ts | 247 - .../services/transaction.service.ts | 589 - .../modules/llm/controllers/llm.controller.ts | 260 - apps/backend/src/modules/llm/llm.routes.ts | 65 - .../src/modules/llm/services/llm.service.ts | 494 - .../ml/controllers/ml-overlay.controller.ts | 248 - .../modules/ml/controllers/ml.controller.ts | 301 - apps/backend/src/modules/ml/ml.routes.ts | 168 - .../ml/services/ml-integration.service.ts | 538 - .../modules/ml/services/ml-overlay.service.ts | 517 - .../controllers/payments.controller.ts | 489 - .../src/modules/payments/payments.routes.ts | 189 - .../payments/services/stripe.service.ts | 437 - .../payments/services/subscription.service.ts | 514 - .../payments/services/wallet.service.ts | 632 - .../modules/payments/types/payments.types.ts | 324 - .../controllers/portfolio.controller.ts | 460 - .../src/modules/portfolio/portfolio.routes.ts | 97 - .../__tests__/portfolio.service.spec.ts | 585 - .../portfolio/services/portfolio.service.ts | 501 - .../trading/controllers/alerts.controller.ts | 189 - .../controllers/indicators.controller.ts | 177 - .../controllers/paper-trading.controller.ts | 253 - .../trading/controllers/trading.controller.ts | 629 - .../controllers/watchlist.controller.ts | 396 - .../services/__tests__/alerts.service.spec.ts | 507 - .../__tests__/paper-trading.service.spec.ts | 473 - .../__tests__/watchlist.service.spec.ts | 372 - .../trading/services/alerts.service.ts | 332 - .../trading/services/binance.service.ts | 542 - .../modules/trading/services/cache.service.ts | 260 - .../trading/services/indicators.service.ts | 538 - .../trading/services/market.service.ts | 479 - .../trading/services/paper-trading.service.ts | 775 -- .../trading/services/watchlist.service.ts | 428 - .../src/modules/trading/trading.routes.ts | 365 - .../src/modules/trading/types/market.types.ts | 104 - .../backend/src/modules/users/users.routes.ts | 64 - apps/backend/src/shared/clients/index.ts | 12 - .../src/shared/clients/llm-agent.client.ts | 414 - .../src/shared/clients/ml-engine.client.ts | 397 - .../shared/clients/trading-agents.client.ts | 363 - .../shared/constants/database.constants.ts | 63 - .../src/shared/constants/enums.constants.ts | 183 - apps/backend/src/shared/constants/index.ts | 14 - .../src/shared/constants/routes.constants.ts | 159 - apps/backend/src/shared/database/index.ts | 110 - .../src/shared/factories/MIGRATION_GUIDE.md | 452 - apps/backend/src/shared/factories/index.ts | 6 - .../src/shared/factories/service.factory.ts | 197 - apps/backend/src/shared/interfaces/README.md | 242 - .../src/shared/interfaces/cache.interface.ts | 78 - .../interfaces/http-client.interface.ts | 43 - apps/backend/src/shared/interfaces/index.ts | 12 - .../interfaces/services/auth.interface.ts | 209 - .../interfaces/services/trading.interface.ts | 442 - .../middleware/validate-dto.middleware.ts | 113 - apps/backend/src/shared/types/common.types.ts | 113 - apps/backend/src/shared/types/index.ts | 5 - apps/backend/src/shared/utils/logger.ts | 37 - apps/backend/test-websocket.html | 506 - apps/backend/test-websocket.js | 137 - apps/backend/tsconfig.json | 29 - apps/data-service/.env.example | 46 - apps/data-service/ARCHITECTURE.md | 682 - apps/data-service/Dockerfile | 48 - apps/data-service/IMPLEMENTATION_SUMMARY.md | 452 - apps/data-service/README.md | 151 - apps/data-service/README_SYNC.md | 375 - apps/data-service/TECH_LEADER_REPORT.md | 603 - apps/data-service/docker-compose.yml | 93 - apps/data-service/environment.yml | 35 - apps/data-service/examples/api_examples.sh | 98 - apps/data-service/examples/sync_example.py | 176 - .../migrations/002_sync_status.sql | 54 - apps/data-service/requirements.txt | 75 - apps/data-service/requirements_sync.txt | 25 - apps/data-service/src/__init__.py | 11 - apps/data-service/src/api/__init__.py | 9 - apps/data-service/src/api/dependencies.py | 103 - apps/data-service/src/api/mt4_routes.py | 555 - apps/data-service/src/api/routes.py | 607 - apps/data-service/src/api/sync_routes.py | 331 - apps/data-service/src/app.py | 200 - apps/data-service/src/app_updated.py | 282 - apps/data-service/src/config.py | 169 - apps/data-service/src/main.py | 366 - apps/data-service/src/models/market.py | 257 - apps/data-service/src/providers/__init__.py | 17 - .../src/providers/binance_client.py | 562 - .../src/providers/metaapi_client.py | 831 -- apps/data-service/src/providers/mt4_client.py | 632 - .../src/providers/polygon_client.py | 479 - apps/data-service/src/services/__init__.py | 9 - .../src/services/price_adjustment.py | 528 - apps/data-service/src/services/scheduler.py | 313 - .../data-service/src/services/sync_service.py | 500 - apps/data-service/src/websocket/__init__.py | 9 - apps/data-service/src/websocket/handlers.py | 184 - apps/data-service/src/websocket/manager.py | 439 - apps/data-service/tests/__init__.py | 3 - apps/data-service/tests/conftest.py | 19 - .../data-service/tests/test_polygon_client.py | 195 - apps/data-service/tests/test_sync_service.py | 227 - .../DIRECTIVA-POLITICA-CARGA-LIMPIA.md | 259 - apps/database/ddl/00-extensions.sql | 26 - apps/database/ddl/01-schemas.sql | 37 - apps/database/ddl/schemas/audit/00-enums.sql | 63 - .../schemas/audit/tables/01-audit_logs.sql | 54 - .../audit/tables/02-security_events.sql | 57 - .../schemas/audit/tables/03-system_events.sql | 47 - .../schemas/audit/tables/04-trading_audit.sql | 57 - .../audit/tables/05-api_request_logs.sql | 49 - .../audit/tables/06-data_access_logs.sql | 45 - .../audit/tables/07-compliance_logs.sql | 52 - .../ddl/schemas/auth/00-extensions.sql | 19 - apps/database/ddl/schemas/auth/01-enums.sql | 80 - .../auth/functions/01-update_updated_at.sql | 48 - .../auth/functions/02-log_auth_event.sql | 75 - .../functions/03-cleanup_expired_sessions.sql | 58 - .../04-create_user_profile_trigger.sql | 46 - .../ddl/schemas/auth/tables/01-users.sql | 107 - .../schemas/auth/tables/02-user_profiles.sql | 70 - .../schemas/auth/tables/03-oauth_accounts.sql | 69 - .../ddl/schemas/auth/tables/04-sessions.sql | 87 - .../auth/tables/05-email_verifications.sql | 65 - .../auth/tables/06-phone_verifications.sql | 78 - .../auth/tables/07-password_reset_tokens.sql | 65 - .../ddl/schemas/auth/tables/08-auth_logs.sql | 74 - .../schemas/auth/tables/09-login_attempts.sql | 67 - .../auth/tables/10-rate_limiting_config.sql | 82 - .../ddl/schemas/education/00-enums.sql | 64 - apps/database/ddl/schemas/education/README.md | 353 - .../ddl/schemas/education/TECHNICAL.md | 458 - .../functions/01-update_updated_at.sql | 69 - .../02-update_enrollment_progress.sql | 57 - .../functions/03-auto_complete_enrollment.sql | 29 - .../functions/04-generate_certificate.sql | 47 - .../functions/05-update_course_stats.sql | 59 - .../functions/06-update_enrollment_count.sql | 41 - .../07-update_gamification_profile.sql | 158 - .../schemas/education/functions/08-views.sql | 142 - .../database/ddl/schemas/education/install.sh | 132 - .../ddl/schemas/education/seeds-example.sql | 238 - .../education/tables/01-categories.sql | 42 - .../schemas/education/tables/02-courses.sql | 74 - .../schemas/education/tables/03-modules.sql | 43 - .../schemas/education/tables/04-lessons.sql | 66 - .../education/tables/05-enrollments.sql | 56 - .../schemas/education/tables/06-progress.sql | 52 - .../schemas/education/tables/07-quizzes.sql | 57 - .../education/tables/08-quiz_questions.sql | 56 - .../education/tables/09-quiz_attempts.sql | 53 - .../education/tables/10-certificates.sql | 54 - .../education/tables/11-user_achievements.sql | 47 - .../tables/12-user_gamification_profile.sql | 56 - .../education/tables/13-user_activity_log.sql | 43 - .../education/tables/14-course_reviews.sql | 48 - .../ddl/schemas/education/uninstall.sh | 55 - apps/database/ddl/schemas/education/verify.sh | 145 - .../ddl/schemas/financial/00-enums.sql | 131 - .../functions/01-update_wallet_balance.sql | 283 - .../functions/02-process_transaction.sql | 326 - .../financial/functions/03-triggers.sql | 278 - .../schemas/financial/functions/04-views.sql | 258 - .../schemas/financial/tables/01-wallets.sql | 85 - .../tables/02-wallet_transactions.sql | 101 - .../financial/tables/03-subscriptions.sql | 107 - .../schemas/financial/tables/04-payments.sql | 86 - .../schemas/financial/tables/05-invoices.sql | 120 - .../financial/tables/06-wallet_audit_log.sql | 68 - .../tables/07-currency_exchange_rates.sql | 131 - .../financial/tables/08-wallet_limits.sql | 101 - .../schemas/financial/tables/09-customers.sql | 68 - .../financial/tables/10-payment_methods.sql | 180 - .../ddl/schemas/investment/00-enums.sql | 52 - .../schemas/investment/tables/01-products.sql | 60 - .../schemas/investment/tables/02-accounts.sql | 67 - .../investment/tables/03-transactions.sql | 69 - .../investment/tables/04-distributions.sql | 69 - .../tables/05-risk_questionnaire.sql | 63 - .../tables/06-withdrawal_requests.sql | 119 - .../tables/07-daily_performance.sql | 115 - apps/database/ddl/schemas/llm/00-enums.sql | 63 - .../schemas/llm/tables/01-conversations.sql | 63 - .../ddl/schemas/llm/tables/02-messages.sql | 98 - .../llm/tables/03-user_preferences.sql | 68 - .../ddl/schemas/llm/tables/04-user_memory.sql | 82 - .../ddl/schemas/llm/tables/05-embeddings.sql | 122 - apps/database/ddl/schemas/ml/00-enums.sql | 68 - .../ddl/schemas/ml/tables/01-models.sql | 65 - .../schemas/ml/tables/02-model_versions.sql | 102 - .../ddl/schemas/ml/tables/03-predictions.sql | 93 - .../ml/tables/04-prediction_outcomes.sql | 68 - .../schemas/ml/tables/05-feature_store.sql | 120 - .../database/ddl/schemas/trading/00-enums.sql | 78 - .../functions/01-calculate_position_pnl.sql | 96 - .../trading/functions/02-update_bot_stats.sql | 88 - .../functions/03-initialize_paper_balance.sql | 165 - .../functions/04-create_default_watchlist.sql | 80 - .../ddl/schemas/trading/tables/01-symbols.sql | 49 - .../schemas/trading/tables/02-watchlists.sql | 40 - .../trading/tables/03-watchlist_items.sql | 38 - .../ddl/schemas/trading/tables/04-bots.sql | 64 - .../ddl/schemas/trading/tables/05-orders.sql | 67 - .../schemas/trading/tables/06-positions.sql | 70 - .../ddl/schemas/trading/tables/07-trades.sql | 49 - .../ddl/schemas/trading/tables/08-signals.sql | 68 - .../trading/tables/09-trading_metrics.sql | 67 - .../trading/tables/10-paper_balances.sql | 51 - apps/database/schemas/00_init_schemas.sql | 123 - apps/database/schemas/01_public_schema.sql | 280 - apps/database/schemas/01b_oauth_providers.sql | 220 - apps/database/schemas/02_education_schema.sql | 398 - apps/database/schemas/03_trading_schema.sql | 428 - .../database/schemas/04_investment_schema.sql | 426 - apps/database/schemas/05_financial_schema.sql | 500 - apps/database/schemas/06_ml_schema.sql | 426 - apps/database/schemas/07_audit_schema.sql | 402 - apps/database/schemas/_MAP.md | 283 - apps/database/scripts/create-database.sh | 308 - .../scripts/drop-and-recreate-database.sh | 13 - apps/database/scripts/migrate_all_tickers.sh | 96 - apps/database/scripts/migrate_direct.sh | 88 - .../scripts/migrate_mysql_to_postgres.py | 393 - apps/database/scripts/validate-ddl.sh | 305 - apps/frontend/.env.example | 16 - apps/frontend/.eslintrc.cjs | 40 - apps/frontend/Dockerfile | 64 - apps/frontend/ML_DASHBOARD_IMPLEMENTATION.md | 318 - apps/frontend/eslint.config.js | 46 - apps/frontend/index.html | 18 - apps/frontend/nginx.conf | 54 - apps/frontend/package-lock.json | 7144 ---------- apps/frontend/package.json | 61 - apps/frontend/postcss.config.js | 6 - apps/frontend/src/App.tsx | 80 - apps/frontend/src/__tests__/mlService.test.ts | 90 - .../src/__tests__/tradingService.test.ts | 135 - .../src/components/chat/ChatInput.tsx | 149 - .../src/components/chat/ChatMessage.tsx | 182 - .../src/components/chat/ChatPanel.tsx | 212 - .../src/components/chat/ChatWidget.tsx | 68 - apps/frontend/src/components/chat/index.ts | 8 - .../src/components/layout/AuthLayout.tsx | 41 - .../src/components/layout/MainLayout.tsx | 147 - apps/frontend/src/hooks/index.ts | 6 - apps/frontend/src/hooks/useMLAnalysis.ts | 291 - apps/frontend/src/main.tsx | 38 - .../admin/components/AgentStatsCard.tsx | 221 - .../modules/admin/components/MLModelCard.tsx | 162 - .../src/modules/admin/components/index.ts | 7 - .../modules/admin/pages/AdminDashboard.tsx | 323 - .../src/modules/admin/pages/AgentsPage.tsx | 286 - .../src/modules/admin/pages/MLModelsPage.tsx | 209 - .../modules/admin/pages/PredictionsPage.tsx | 366 - .../frontend/src/modules/admin/pages/index.ts | 9 - .../assistant/components/ChatInput.tsx | 135 - .../assistant/components/ChatMessage.tsx | 85 - .../assistant/components/SignalCard.tsx | 156 - .../src/modules/assistant/pages/Assistant.tsx | 272 - .../auth/components/PhoneLoginForm.tsx | 264 - .../auth/components/SocialLoginButtons.tsx | 129 - .../src/modules/auth/pages/AuthCallback.tsx | 96 - .../src/modules/auth/pages/ForgotPassword.tsx | 119 - .../frontend/src/modules/auth/pages/Login.tsx | 230 - .../src/modules/auth/pages/Register.tsx | 257 - .../src/modules/auth/pages/ResetPassword.tsx | 209 - .../src/modules/auth/pages/VerifyEmail.tsx | 98 - .../components/EquityCurveChart.tsx | 249 - .../components/PerformanceMetricsPanel.tsx | 339 - .../components/PredictionChart.tsx | 344 - .../components/StrategyComparisonChart.tsx | 284 - .../backtesting/components/TradesTable.tsx | 361 - .../modules/backtesting/components/index.ts | 10 - .../pages/BacktestingDashboard.tsx | 636 - .../src/modules/dashboard/pages/Dashboard.tsx | 77 - .../modules/education/pages/CourseDetail.tsx | 18 - .../src/modules/education/pages/Courses.tsx | 98 - .../modules/investment/pages/Investment.tsx | 100 - .../modules/investment/pages/Portfolio.tsx | 346 - .../src/modules/investment/pages/Products.tsx | 276 - apps/frontend/src/modules/ml/README.md | 204 - .../frontend/src/modules/ml/USAGE_EXAMPLES.md | 584 - .../src/modules/ml/VALIDATION_CHECKLIST.md | 245 - .../ml/components/AMDPhaseIndicator.tsx | 212 - .../modules/ml/components/AccuracyMetrics.tsx | 202 - .../ml/components/EnsembleSignalCard.tsx | 285 - .../modules/ml/components/ICTAnalysisCard.tsx | 293 - .../modules/ml/components/PredictionCard.tsx | 203 - .../modules/ml/components/SignalsTimeline.tsx | 216 - .../ml/components/TradeExecutionModal.tsx | 349 - .../src/modules/ml/components/index.ts | 12 - .../src/modules/ml/pages/MLDashboard.tsx | 567 - .../src/modules/settings/pages/Settings.tsx | 89 - .../trading/components/AccountSummary.tsx | 64 - .../trading/components/AddSymbolModal.tsx | 227 - .../trading/components/CandlestickChart.tsx | 243 - .../trading/components/ChartToolbar.tsx | 272 - .../trading/components/MLSignalsPanel.tsx | 418 - .../modules/trading/components/OrderForm.tsx | 259 - .../trading/components/PaperTradingPanel.tsx | 162 - .../trading/components/PositionsList.tsx | 173 - .../trading/components/TradesHistory.tsx | 132 - .../trading/components/TradingChart.tsx | 459 - .../trading/components/WatchlistItem.tsx | 149 - .../trading/components/WatchlistSidebar.tsx | 219 - .../src/modules/trading/pages/Trading.tsx | 271 - apps/frontend/src/services/adminService.ts | 421 - apps/frontend/src/services/backtestService.ts | 514 - apps/frontend/src/services/chat.service.ts | 111 - apps/frontend/src/services/mlService.ts | 377 - apps/frontend/src/services/trading.service.ts | 847 -- .../src/services/websocket.service.ts | 353 - apps/frontend/src/stores/chatStore.ts | 332 - apps/frontend/src/stores/tradingStore.ts | 405 - apps/frontend/src/styles/index.css | 162 - apps/frontend/src/types/chat.types.ts | 40 - apps/frontend/src/types/trading.types.ts | 325 - apps/frontend/src/vite-env.d.ts | 10 - apps/frontend/tailwind.config.js | 44 - apps/frontend/tsconfig.json | 32 - apps/frontend/tsconfig.node.json | 10 - apps/frontend/vite.config.ts | 28 - apps/llm-agent/.env.example | 70 - apps/llm-agent/AUTO_TRADING.md | 369 - apps/llm-agent/DEPLOYMENT.md | 494 - apps/llm-agent/Dockerfile | 35 - apps/llm-agent/IMPLEMENTATION_SUMMARY.md | 525 - apps/llm-agent/README.md | 286 - apps/llm-agent/docker-compose.ollama.yml | 49 - apps/llm-agent/environment.yml | 62 - .../examples/auto_trading_example.py | 270 - apps/llm-agent/pyproject.toml | 79 - apps/llm-agent/requirements.txt | 59 - apps/llm-agent/src/__init__.py | 6 - apps/llm-agent/src/api/__init__.py | 3 - apps/llm-agent/src/api/auto_trade_routes.py | 421 - apps/llm-agent/src/api/routes.py | 387 - apps/llm-agent/src/clients/__init__.py | 13 - apps/llm-agent/src/clients/mt4_client.py | 422 - apps/llm-agent/src/config.py | 83 - apps/llm-agent/src/core/__init__.py | 1 - apps/llm-agent/src/core/context_manager.py | 198 - apps/llm-agent/src/core/llm_client.py | 681 - apps/llm-agent/src/core/prompt_manager.py | 176 - apps/llm-agent/src/main.py | 81 - apps/llm-agent/src/models/__init__.py | 4 - apps/llm-agent/src/models/auto_trade.py | 134 - apps/llm-agent/src/prompts/analysis.txt | 36 - apps/llm-agent/src/prompts/strategy.txt | 65 - apps/llm-agent/src/prompts/system.txt | 94 - .../llm-agent/src/prompts/trade_execution.txt | 52 - apps/llm-agent/src/repositories/__init__.py | 4 - apps/llm-agent/src/services/__init__.py | 4 - .../src/services/auto_trade_service.py | 673 - apps/llm-agent/src/tools/__init__.py | 63 - apps/llm-agent/src/tools/auto_trading.py | 356 - apps/llm-agent/src/tools/base.py | 177 - apps/llm-agent/src/tools/education.py | 293 - apps/llm-agent/src/tools/ml_tools.py | 477 - apps/llm-agent/src/tools/mt4_tools.py | 559 - apps/llm-agent/src/tools/portfolio.py | 255 - apps/llm-agent/src/tools/signals.py | 268 - apps/llm-agent/src/tools/trading.py | 487 - apps/llm-agent/tests/__init__.py | 3 - apps/llm-agent/tests/conftest.py | 26 - apps/llm-agent/tests/test_auto_trading.py | 293 - apps/llm-agent/tests/test_mt4_integration.py | 304 - apps/ml-engine/.env.example | 50 - apps/ml-engine/Dockerfile | 36 - apps/ml-engine/MIGRATION_REPORT.md | 436 - apps/ml-engine/config/database.yaml | 32 - apps/ml-engine/config/models.yaml | 144 - apps/ml-engine/config/phase2.yaml | 289 - apps/ml-engine/config/trading.yaml | 211 - apps/ml-engine/environment.yml | 54 - apps/ml-engine/pytest.ini | 9 - apps/ml-engine/requirements.txt | 45 - apps/ml-engine/src/__init__.py | 17 - apps/ml-engine/src/api/__init__.py | 10 - apps/ml-engine/src/api/main.py | 1089 -- apps/ml-engine/src/backtesting/__init__.py | 19 - apps/ml-engine/src/backtesting/engine.py | 517 - apps/ml-engine/src/backtesting/metrics.py | 587 - .../src/backtesting/rr_backtester.py | 566 - apps/ml-engine/src/data/__init__.py | 32 - .../ml-engine/src/data/data_service_client.py | 417 - apps/ml-engine/src/data/database.py | 370 - apps/ml-engine/src/data/features.py | 291 - apps/ml-engine/src/data/indicators.py | 345 - apps/ml-engine/src/data/pipeline.py | 419 - apps/ml-engine/src/data/targets.py | 621 - apps/ml-engine/src/data/validators.py | 616 - apps/ml-engine/src/models/__init__.py | 63 - apps/ml-engine/src/models/amd_detector.py | 570 - apps/ml-engine/src/models/amd_models.py | 628 - apps/ml-engine/src/models/ict_smc_detector.py | 1042 -- apps/ml-engine/src/models/range_predictor.py | 572 - apps/ml-engine/src/models/signal_generator.py | 529 - .../ml-engine/src/models/strategy_ensemble.py | 809 -- apps/ml-engine/src/models/tp_sl_classifier.py | 658 - apps/ml-engine/src/pipelines/__init__.py | 7 - .../src/pipelines/phase2_pipeline.py | 604 - apps/ml-engine/src/services/__init__.py | 6 - .../src/services/prediction_service.py | 628 - apps/ml-engine/src/training/__init__.py | 11 - apps/ml-engine/src/training/walk_forward.py | 453 - apps/ml-engine/src/utils/__init__.py | 12 - apps/ml-engine/src/utils/audit.py | 772 -- apps/ml-engine/src/utils/signal_logger.py | 546 - apps/ml-engine/tests/__init__.py | 1 - apps/ml-engine/tests/test_amd_detector.py | 170 - apps/ml-engine/tests/test_api.py | 191 - apps/ml-engine/tests/test_ict_detector.py | 267 - apps/mt4-gateway/.env.example | 57 - apps/mt4-gateway/config/agents.yml | 184 - apps/mt4-gateway/requirements.txt | 37 - apps/mt4-gateway/src/__init__.py | 0 apps/mt4-gateway/src/main.py | 546 - apps/mt4-gateway/src/providers/__init__.py | 0 .../src/providers/mt4_bridge_client.py | 496 - apps/mt4-gateway/src/services/__init__.py | 0 apps/personal/.env.example | 109 - apps/personal/config.yaml | 169 - apps/personal/package.json | 20 - apps/personal/scripts/setup-personal.ts | 304 - apps/personal/scripts/validate-config.ts | 213 - apps/trading-agents/.env.example | 29 - apps/trading-agents/Dockerfile | 29 - apps/trading-agents/IMPLEMENTATION_REPORT.md | 498 - apps/trading-agents/INTEGRATION.md | 587 - apps/trading-agents/PAPER_TRADING_GUIDE.md | 321 - apps/trading-agents/README.md | 335 - apps/trading-agents/config/agents.yaml | 146 - apps/trading-agents/config/risk.yaml | 208 - apps/trading-agents/config/strategies.yaml | 165 - apps/trading-agents/docker-compose.yml | 76 - apps/trading-agents/example_usage.py | 215 - apps/trading-agents/requirements.txt | 55 - apps/trading-agents/src/__init__.py | 5 - apps/trading-agents/src/agents/__init__.py | 16 - apps/trading-agents/src/agents/atlas.py | 263 - apps/trading-agents/src/agents/base.py | 320 - apps/trading-agents/src/agents/nova.py | 305 - apps/trading-agents/src/agents/orion.py | 299 - apps/trading-agents/src/api/main.py | 260 - apps/trading-agents/src/exchange/__init__.py | 7 - .../src/exchange/binance_client.py | 340 - apps/trading-agents/src/execution/__init__.py | 8 - .../src/execution/risk_manager.py | 319 - apps/trading-agents/src/signals/__init__.py | 7 - .../trading-agents/src/signals/ml_consumer.py | 227 - .../trading-agents/src/strategies/__init__.py | 17 - apps/trading-agents/src/strategies/base.py | 82 - .../src/strategies/grid_trading.py | 182 - .../src/strategies/mean_reversion.py | 170 - .../trading-agents/src/strategies/momentum.py | 147 - .../src/strategies/trend_following.py | 192 - 532 files changed, 67 insertions(+), 131731 deletions(-) create mode 100644 .gitignore create mode 100644 .gitmodules delete mode 100644 apps/backend/.env.example delete mode 100644 apps/backend/Dockerfile delete mode 100644 apps/backend/WEBSOCKET_IMPLEMENTATION_REPORT.md delete mode 100644 apps/backend/WEBSOCKET_TESTING.md delete mode 100644 apps/backend/eslint.config.js delete mode 100644 apps/backend/jest.config.ts delete mode 100644 apps/backend/package-lock.json delete mode 100644 apps/backend/package.json delete mode 100644 apps/backend/service.descriptor.yml delete mode 100644 apps/backend/src/__tests__/jest-migration.test.ts delete mode 100644 apps/backend/src/__tests__/mocks/database.mock.ts delete mode 100644 apps/backend/src/__tests__/mocks/email.mock.ts delete mode 100644 apps/backend/src/__tests__/mocks/redis.mock.ts delete mode 100644 apps/backend/src/__tests__/setup.ts delete mode 100644 apps/backend/src/config/index.ts delete mode 100644 apps/backend/src/config/swagger.config.ts delete mode 100644 apps/backend/src/core/filters/http-exception.filter.ts delete mode 100644 apps/backend/src/core/filters/index.ts delete mode 100644 apps/backend/src/core/guards/auth.guard.ts delete mode 100644 apps/backend/src/core/guards/index.ts delete mode 100644 apps/backend/src/core/interceptors/index.ts delete mode 100644 apps/backend/src/core/interceptors/transform-response.interceptor.ts delete mode 100644 apps/backend/src/core/middleware/auth.middleware.ts delete mode 100644 apps/backend/src/core/middleware/error-handler.ts delete mode 100644 apps/backend/src/core/middleware/not-found.ts delete mode 100644 apps/backend/src/core/middleware/rate-limiter.ts delete mode 100644 apps/backend/src/core/websocket/index.ts delete mode 100644 apps/backend/src/core/websocket/trading-stream.service.ts delete mode 100644 apps/backend/src/core/websocket/websocket.server.ts delete mode 100644 apps/backend/src/docs/openapi.yaml delete mode 100644 apps/backend/src/index.ts delete mode 100644 apps/backend/src/modules/admin/admin.routes.ts delete mode 100644 apps/backend/src/modules/agents/agents.routes.ts delete mode 100644 apps/backend/src/modules/agents/controllers/agents.controller.ts delete mode 100644 apps/backend/src/modules/agents/services/agents.service.ts delete mode 100644 apps/backend/src/modules/auth/auth.routes.ts delete mode 100644 apps/backend/src/modules/auth/controllers/auth.controller.ts delete mode 100644 apps/backend/src/modules/auth/controllers/email-auth.controller.ts delete mode 100644 apps/backend/src/modules/auth/controllers/index.ts delete mode 100644 apps/backend/src/modules/auth/controllers/oauth.controller.ts delete mode 100644 apps/backend/src/modules/auth/controllers/phone-auth.controller.ts delete mode 100644 apps/backend/src/modules/auth/controllers/token.controller.ts delete mode 100644 apps/backend/src/modules/auth/controllers/two-factor.controller.ts delete mode 100644 apps/backend/src/modules/auth/dto/change-password.dto.ts delete mode 100644 apps/backend/src/modules/auth/dto/index.ts delete mode 100644 apps/backend/src/modules/auth/dto/login.dto.ts delete mode 100644 apps/backend/src/modules/auth/dto/oauth.dto.ts delete mode 100644 apps/backend/src/modules/auth/dto/refresh-token.dto.ts delete mode 100644 apps/backend/src/modules/auth/dto/register.dto.ts delete mode 100644 apps/backend/src/modules/auth/services/__tests__/email.service.spec.ts delete mode 100644 apps/backend/src/modules/auth/services/__tests__/token.service.spec.ts delete mode 100644 apps/backend/src/modules/auth/services/email.service.ts delete mode 100644 apps/backend/src/modules/auth/services/oauth.service.ts delete mode 100644 apps/backend/src/modules/auth/services/phone.service.ts delete mode 100644 apps/backend/src/modules/auth/services/token.service.ts delete mode 100644 apps/backend/src/modules/auth/services/twofa.service.ts delete mode 100644 apps/backend/src/modules/auth/stores/__tests__/oauth-state.store.spec.ts delete mode 100644 apps/backend/src/modules/auth/stores/oauth-state.store.ts delete mode 100644 apps/backend/src/modules/auth/types/auth.types.ts delete mode 100644 apps/backend/src/modules/auth/validators/auth.validators.ts delete mode 100644 apps/backend/src/modules/education/controllers/education.controller.ts delete mode 100644 apps/backend/src/modules/education/education.routes.ts delete mode 100644 apps/backend/src/modules/education/services/course.service.ts delete mode 100644 apps/backend/src/modules/education/services/enrollment.service.ts delete mode 100644 apps/backend/src/modules/education/types/education.types.ts delete mode 100644 apps/backend/src/modules/investment/controllers/investment.controller.ts delete mode 100644 apps/backend/src/modules/investment/investment.routes.ts delete mode 100644 apps/backend/src/modules/investment/services/__tests__/account.service.spec.ts delete mode 100644 apps/backend/src/modules/investment/services/__tests__/product.service.spec.ts delete mode 100644 apps/backend/src/modules/investment/services/__tests__/transaction.service.spec.ts delete mode 100644 apps/backend/src/modules/investment/services/account.service.ts delete mode 100644 apps/backend/src/modules/investment/services/product.service.ts delete mode 100644 apps/backend/src/modules/investment/services/transaction.service.ts delete mode 100644 apps/backend/src/modules/llm/controllers/llm.controller.ts delete mode 100644 apps/backend/src/modules/llm/llm.routes.ts delete mode 100644 apps/backend/src/modules/llm/services/llm.service.ts delete mode 100644 apps/backend/src/modules/ml/controllers/ml-overlay.controller.ts delete mode 100644 apps/backend/src/modules/ml/controllers/ml.controller.ts delete mode 100644 apps/backend/src/modules/ml/ml.routes.ts delete mode 100644 apps/backend/src/modules/ml/services/ml-integration.service.ts delete mode 100644 apps/backend/src/modules/ml/services/ml-overlay.service.ts delete mode 100644 apps/backend/src/modules/payments/controllers/payments.controller.ts delete mode 100644 apps/backend/src/modules/payments/payments.routes.ts delete mode 100644 apps/backend/src/modules/payments/services/stripe.service.ts delete mode 100644 apps/backend/src/modules/payments/services/subscription.service.ts delete mode 100644 apps/backend/src/modules/payments/services/wallet.service.ts delete mode 100644 apps/backend/src/modules/payments/types/payments.types.ts delete mode 100644 apps/backend/src/modules/portfolio/controllers/portfolio.controller.ts delete mode 100644 apps/backend/src/modules/portfolio/portfolio.routes.ts delete mode 100644 apps/backend/src/modules/portfolio/services/__tests__/portfolio.service.spec.ts delete mode 100644 apps/backend/src/modules/portfolio/services/portfolio.service.ts delete mode 100644 apps/backend/src/modules/trading/controllers/alerts.controller.ts delete mode 100644 apps/backend/src/modules/trading/controllers/indicators.controller.ts delete mode 100644 apps/backend/src/modules/trading/controllers/paper-trading.controller.ts delete mode 100644 apps/backend/src/modules/trading/controllers/trading.controller.ts delete mode 100644 apps/backend/src/modules/trading/controllers/watchlist.controller.ts delete mode 100644 apps/backend/src/modules/trading/services/__tests__/alerts.service.spec.ts delete mode 100644 apps/backend/src/modules/trading/services/__tests__/paper-trading.service.spec.ts delete mode 100644 apps/backend/src/modules/trading/services/__tests__/watchlist.service.spec.ts delete mode 100644 apps/backend/src/modules/trading/services/alerts.service.ts delete mode 100644 apps/backend/src/modules/trading/services/binance.service.ts delete mode 100644 apps/backend/src/modules/trading/services/cache.service.ts delete mode 100644 apps/backend/src/modules/trading/services/indicators.service.ts delete mode 100644 apps/backend/src/modules/trading/services/market.service.ts delete mode 100644 apps/backend/src/modules/trading/services/paper-trading.service.ts delete mode 100644 apps/backend/src/modules/trading/services/watchlist.service.ts delete mode 100644 apps/backend/src/modules/trading/trading.routes.ts delete mode 100644 apps/backend/src/modules/trading/types/market.types.ts delete mode 100644 apps/backend/src/modules/users/users.routes.ts delete mode 100644 apps/backend/src/shared/clients/index.ts delete mode 100644 apps/backend/src/shared/clients/llm-agent.client.ts delete mode 100644 apps/backend/src/shared/clients/ml-engine.client.ts delete mode 100644 apps/backend/src/shared/clients/trading-agents.client.ts delete mode 100644 apps/backend/src/shared/constants/database.constants.ts delete mode 100644 apps/backend/src/shared/constants/enums.constants.ts delete mode 100644 apps/backend/src/shared/constants/index.ts delete mode 100644 apps/backend/src/shared/constants/routes.constants.ts delete mode 100644 apps/backend/src/shared/database/index.ts delete mode 100644 apps/backend/src/shared/factories/MIGRATION_GUIDE.md delete mode 100644 apps/backend/src/shared/factories/index.ts delete mode 100644 apps/backend/src/shared/factories/service.factory.ts delete mode 100644 apps/backend/src/shared/interfaces/README.md delete mode 100644 apps/backend/src/shared/interfaces/cache.interface.ts delete mode 100644 apps/backend/src/shared/interfaces/http-client.interface.ts delete mode 100644 apps/backend/src/shared/interfaces/index.ts delete mode 100644 apps/backend/src/shared/interfaces/services/auth.interface.ts delete mode 100644 apps/backend/src/shared/interfaces/services/trading.interface.ts delete mode 100644 apps/backend/src/shared/middleware/validate-dto.middleware.ts delete mode 100644 apps/backend/src/shared/types/common.types.ts delete mode 100644 apps/backend/src/shared/types/index.ts delete mode 100644 apps/backend/src/shared/utils/logger.ts delete mode 100644 apps/backend/test-websocket.html delete mode 100644 apps/backend/test-websocket.js delete mode 100644 apps/backend/tsconfig.json delete mode 100644 apps/data-service/.env.example delete mode 100644 apps/data-service/ARCHITECTURE.md delete mode 100644 apps/data-service/Dockerfile delete mode 100644 apps/data-service/IMPLEMENTATION_SUMMARY.md delete mode 100644 apps/data-service/README.md delete mode 100644 apps/data-service/README_SYNC.md delete mode 100644 apps/data-service/TECH_LEADER_REPORT.md delete mode 100644 apps/data-service/docker-compose.yml delete mode 100644 apps/data-service/environment.yml delete mode 100755 apps/data-service/examples/api_examples.sh delete mode 100644 apps/data-service/examples/sync_example.py delete mode 100644 apps/data-service/migrations/002_sync_status.sql delete mode 100644 apps/data-service/requirements.txt delete mode 100644 apps/data-service/requirements_sync.txt delete mode 100644 apps/data-service/src/__init__.py delete mode 100644 apps/data-service/src/api/__init__.py delete mode 100644 apps/data-service/src/api/dependencies.py delete mode 100644 apps/data-service/src/api/mt4_routes.py delete mode 100644 apps/data-service/src/api/routes.py delete mode 100644 apps/data-service/src/api/sync_routes.py delete mode 100644 apps/data-service/src/app.py delete mode 100644 apps/data-service/src/app_updated.py delete mode 100644 apps/data-service/src/config.py delete mode 100644 apps/data-service/src/main.py delete mode 100644 apps/data-service/src/models/market.py delete mode 100644 apps/data-service/src/providers/__init__.py delete mode 100644 apps/data-service/src/providers/binance_client.py delete mode 100644 apps/data-service/src/providers/metaapi_client.py delete mode 100644 apps/data-service/src/providers/mt4_client.py delete mode 100644 apps/data-service/src/providers/polygon_client.py delete mode 100644 apps/data-service/src/services/__init__.py delete mode 100644 apps/data-service/src/services/price_adjustment.py delete mode 100644 apps/data-service/src/services/scheduler.py delete mode 100644 apps/data-service/src/services/sync_service.py delete mode 100644 apps/data-service/src/websocket/__init__.py delete mode 100644 apps/data-service/src/websocket/handlers.py delete mode 100644 apps/data-service/src/websocket/manager.py delete mode 100644 apps/data-service/tests/__init__.py delete mode 100644 apps/data-service/tests/conftest.py delete mode 100644 apps/data-service/tests/test_polygon_client.py delete mode 100644 apps/data-service/tests/test_sync_service.py delete mode 100644 apps/database/DIRECTIVA-POLITICA-CARGA-LIMPIA.md delete mode 100644 apps/database/ddl/00-extensions.sql delete mode 100644 apps/database/ddl/01-schemas.sql delete mode 100644 apps/database/ddl/schemas/audit/00-enums.sql delete mode 100644 apps/database/ddl/schemas/audit/tables/01-audit_logs.sql delete mode 100644 apps/database/ddl/schemas/audit/tables/02-security_events.sql delete mode 100644 apps/database/ddl/schemas/audit/tables/03-system_events.sql delete mode 100644 apps/database/ddl/schemas/audit/tables/04-trading_audit.sql delete mode 100644 apps/database/ddl/schemas/audit/tables/05-api_request_logs.sql delete mode 100644 apps/database/ddl/schemas/audit/tables/06-data_access_logs.sql delete mode 100644 apps/database/ddl/schemas/audit/tables/07-compliance_logs.sql delete mode 100644 apps/database/ddl/schemas/auth/00-extensions.sql delete mode 100644 apps/database/ddl/schemas/auth/01-enums.sql delete mode 100644 apps/database/ddl/schemas/auth/functions/01-update_updated_at.sql delete mode 100644 apps/database/ddl/schemas/auth/functions/02-log_auth_event.sql delete mode 100644 apps/database/ddl/schemas/auth/functions/03-cleanup_expired_sessions.sql delete mode 100644 apps/database/ddl/schemas/auth/functions/04-create_user_profile_trigger.sql delete mode 100644 apps/database/ddl/schemas/auth/tables/01-users.sql delete mode 100644 apps/database/ddl/schemas/auth/tables/02-user_profiles.sql delete mode 100644 apps/database/ddl/schemas/auth/tables/03-oauth_accounts.sql delete mode 100644 apps/database/ddl/schemas/auth/tables/04-sessions.sql delete mode 100644 apps/database/ddl/schemas/auth/tables/05-email_verifications.sql delete mode 100644 apps/database/ddl/schemas/auth/tables/06-phone_verifications.sql delete mode 100644 apps/database/ddl/schemas/auth/tables/07-password_reset_tokens.sql delete mode 100644 apps/database/ddl/schemas/auth/tables/08-auth_logs.sql delete mode 100644 apps/database/ddl/schemas/auth/tables/09-login_attempts.sql delete mode 100644 apps/database/ddl/schemas/auth/tables/10-rate_limiting_config.sql delete mode 100644 apps/database/ddl/schemas/education/00-enums.sql delete mode 100644 apps/database/ddl/schemas/education/README.md delete mode 100644 apps/database/ddl/schemas/education/TECHNICAL.md delete mode 100644 apps/database/ddl/schemas/education/functions/01-update_updated_at.sql delete mode 100644 apps/database/ddl/schemas/education/functions/02-update_enrollment_progress.sql delete mode 100644 apps/database/ddl/schemas/education/functions/03-auto_complete_enrollment.sql delete mode 100644 apps/database/ddl/schemas/education/functions/04-generate_certificate.sql delete mode 100644 apps/database/ddl/schemas/education/functions/05-update_course_stats.sql delete mode 100644 apps/database/ddl/schemas/education/functions/06-update_enrollment_count.sql delete mode 100644 apps/database/ddl/schemas/education/functions/07-update_gamification_profile.sql delete mode 100644 apps/database/ddl/schemas/education/functions/08-views.sql delete mode 100755 apps/database/ddl/schemas/education/install.sh delete mode 100644 apps/database/ddl/schemas/education/seeds-example.sql delete mode 100644 apps/database/ddl/schemas/education/tables/01-categories.sql delete mode 100644 apps/database/ddl/schemas/education/tables/02-courses.sql delete mode 100644 apps/database/ddl/schemas/education/tables/03-modules.sql delete mode 100644 apps/database/ddl/schemas/education/tables/04-lessons.sql delete mode 100644 apps/database/ddl/schemas/education/tables/05-enrollments.sql delete mode 100644 apps/database/ddl/schemas/education/tables/06-progress.sql delete mode 100644 apps/database/ddl/schemas/education/tables/07-quizzes.sql delete mode 100644 apps/database/ddl/schemas/education/tables/08-quiz_questions.sql delete mode 100644 apps/database/ddl/schemas/education/tables/09-quiz_attempts.sql delete mode 100644 apps/database/ddl/schemas/education/tables/10-certificates.sql delete mode 100644 apps/database/ddl/schemas/education/tables/11-user_achievements.sql delete mode 100644 apps/database/ddl/schemas/education/tables/12-user_gamification_profile.sql delete mode 100644 apps/database/ddl/schemas/education/tables/13-user_activity_log.sql delete mode 100644 apps/database/ddl/schemas/education/tables/14-course_reviews.sql delete mode 100755 apps/database/ddl/schemas/education/uninstall.sh delete mode 100755 apps/database/ddl/schemas/education/verify.sh delete mode 100644 apps/database/ddl/schemas/financial/00-enums.sql delete mode 100644 apps/database/ddl/schemas/financial/functions/01-update_wallet_balance.sql delete mode 100644 apps/database/ddl/schemas/financial/functions/02-process_transaction.sql delete mode 100644 apps/database/ddl/schemas/financial/functions/03-triggers.sql delete mode 100644 apps/database/ddl/schemas/financial/functions/04-views.sql delete mode 100644 apps/database/ddl/schemas/financial/tables/01-wallets.sql delete mode 100644 apps/database/ddl/schemas/financial/tables/02-wallet_transactions.sql delete mode 100644 apps/database/ddl/schemas/financial/tables/03-subscriptions.sql delete mode 100644 apps/database/ddl/schemas/financial/tables/04-payments.sql delete mode 100644 apps/database/ddl/schemas/financial/tables/05-invoices.sql delete mode 100644 apps/database/ddl/schemas/financial/tables/06-wallet_audit_log.sql delete mode 100644 apps/database/ddl/schemas/financial/tables/07-currency_exchange_rates.sql delete mode 100644 apps/database/ddl/schemas/financial/tables/08-wallet_limits.sql delete mode 100644 apps/database/ddl/schemas/financial/tables/09-customers.sql delete mode 100644 apps/database/ddl/schemas/financial/tables/10-payment_methods.sql delete mode 100644 apps/database/ddl/schemas/investment/00-enums.sql delete mode 100644 apps/database/ddl/schemas/investment/tables/01-products.sql delete mode 100644 apps/database/ddl/schemas/investment/tables/02-accounts.sql delete mode 100644 apps/database/ddl/schemas/investment/tables/03-transactions.sql delete mode 100644 apps/database/ddl/schemas/investment/tables/04-distributions.sql delete mode 100644 apps/database/ddl/schemas/investment/tables/05-risk_questionnaire.sql delete mode 100644 apps/database/ddl/schemas/investment/tables/06-withdrawal_requests.sql delete mode 100644 apps/database/ddl/schemas/investment/tables/07-daily_performance.sql delete mode 100644 apps/database/ddl/schemas/llm/00-enums.sql delete mode 100644 apps/database/ddl/schemas/llm/tables/01-conversations.sql delete mode 100644 apps/database/ddl/schemas/llm/tables/02-messages.sql delete mode 100644 apps/database/ddl/schemas/llm/tables/03-user_preferences.sql delete mode 100644 apps/database/ddl/schemas/llm/tables/04-user_memory.sql delete mode 100644 apps/database/ddl/schemas/llm/tables/05-embeddings.sql delete mode 100644 apps/database/ddl/schemas/ml/00-enums.sql delete mode 100644 apps/database/ddl/schemas/ml/tables/01-models.sql delete mode 100644 apps/database/ddl/schemas/ml/tables/02-model_versions.sql delete mode 100644 apps/database/ddl/schemas/ml/tables/03-predictions.sql delete mode 100644 apps/database/ddl/schemas/ml/tables/04-prediction_outcomes.sql delete mode 100644 apps/database/ddl/schemas/ml/tables/05-feature_store.sql delete mode 100644 apps/database/ddl/schemas/trading/00-enums.sql delete mode 100644 apps/database/ddl/schemas/trading/functions/01-calculate_position_pnl.sql delete mode 100644 apps/database/ddl/schemas/trading/functions/02-update_bot_stats.sql delete mode 100644 apps/database/ddl/schemas/trading/functions/03-initialize_paper_balance.sql delete mode 100644 apps/database/ddl/schemas/trading/functions/04-create_default_watchlist.sql delete mode 100644 apps/database/ddl/schemas/trading/tables/01-symbols.sql delete mode 100644 apps/database/ddl/schemas/trading/tables/02-watchlists.sql delete mode 100644 apps/database/ddl/schemas/trading/tables/03-watchlist_items.sql delete mode 100644 apps/database/ddl/schemas/trading/tables/04-bots.sql delete mode 100644 apps/database/ddl/schemas/trading/tables/05-orders.sql delete mode 100644 apps/database/ddl/schemas/trading/tables/06-positions.sql delete mode 100644 apps/database/ddl/schemas/trading/tables/07-trades.sql delete mode 100644 apps/database/ddl/schemas/trading/tables/08-signals.sql delete mode 100644 apps/database/ddl/schemas/trading/tables/09-trading_metrics.sql delete mode 100644 apps/database/ddl/schemas/trading/tables/10-paper_balances.sql delete mode 100644 apps/database/schemas/00_init_schemas.sql delete mode 100644 apps/database/schemas/01_public_schema.sql delete mode 100644 apps/database/schemas/01b_oauth_providers.sql delete mode 100644 apps/database/schemas/02_education_schema.sql delete mode 100644 apps/database/schemas/03_trading_schema.sql delete mode 100644 apps/database/schemas/04_investment_schema.sql delete mode 100644 apps/database/schemas/05_financial_schema.sql delete mode 100644 apps/database/schemas/06_ml_schema.sql delete mode 100644 apps/database/schemas/07_audit_schema.sql delete mode 100644 apps/database/schemas/_MAP.md delete mode 100755 apps/database/scripts/create-database.sh delete mode 100755 apps/database/scripts/drop-and-recreate-database.sh delete mode 100755 apps/database/scripts/migrate_all_tickers.sh delete mode 100755 apps/database/scripts/migrate_direct.sh delete mode 100644 apps/database/scripts/migrate_mysql_to_postgres.py delete mode 100644 apps/database/scripts/validate-ddl.sh delete mode 100644 apps/frontend/.env.example delete mode 100644 apps/frontend/.eslintrc.cjs delete mode 100644 apps/frontend/Dockerfile delete mode 100644 apps/frontend/ML_DASHBOARD_IMPLEMENTATION.md delete mode 100644 apps/frontend/eslint.config.js delete mode 100644 apps/frontend/index.html delete mode 100644 apps/frontend/nginx.conf delete mode 100644 apps/frontend/package-lock.json delete mode 100644 apps/frontend/package.json delete mode 100644 apps/frontend/postcss.config.js delete mode 100644 apps/frontend/src/App.tsx delete mode 100644 apps/frontend/src/__tests__/mlService.test.ts delete mode 100644 apps/frontend/src/__tests__/tradingService.test.ts delete mode 100644 apps/frontend/src/components/chat/ChatInput.tsx delete mode 100644 apps/frontend/src/components/chat/ChatMessage.tsx delete mode 100644 apps/frontend/src/components/chat/ChatPanel.tsx delete mode 100644 apps/frontend/src/components/chat/ChatWidget.tsx delete mode 100644 apps/frontend/src/components/chat/index.ts delete mode 100644 apps/frontend/src/components/layout/AuthLayout.tsx delete mode 100644 apps/frontend/src/components/layout/MainLayout.tsx delete mode 100644 apps/frontend/src/hooks/index.ts delete mode 100644 apps/frontend/src/hooks/useMLAnalysis.ts delete mode 100644 apps/frontend/src/main.tsx delete mode 100644 apps/frontend/src/modules/admin/components/AgentStatsCard.tsx delete mode 100644 apps/frontend/src/modules/admin/components/MLModelCard.tsx delete mode 100644 apps/frontend/src/modules/admin/components/index.ts delete mode 100644 apps/frontend/src/modules/admin/pages/AdminDashboard.tsx delete mode 100644 apps/frontend/src/modules/admin/pages/AgentsPage.tsx delete mode 100644 apps/frontend/src/modules/admin/pages/MLModelsPage.tsx delete mode 100644 apps/frontend/src/modules/admin/pages/PredictionsPage.tsx delete mode 100644 apps/frontend/src/modules/admin/pages/index.ts delete mode 100644 apps/frontend/src/modules/assistant/components/ChatInput.tsx delete mode 100644 apps/frontend/src/modules/assistant/components/ChatMessage.tsx delete mode 100644 apps/frontend/src/modules/assistant/components/SignalCard.tsx delete mode 100644 apps/frontend/src/modules/assistant/pages/Assistant.tsx delete mode 100644 apps/frontend/src/modules/auth/components/PhoneLoginForm.tsx delete mode 100644 apps/frontend/src/modules/auth/components/SocialLoginButtons.tsx delete mode 100644 apps/frontend/src/modules/auth/pages/AuthCallback.tsx delete mode 100644 apps/frontend/src/modules/auth/pages/ForgotPassword.tsx delete mode 100644 apps/frontend/src/modules/auth/pages/Login.tsx delete mode 100644 apps/frontend/src/modules/auth/pages/Register.tsx delete mode 100644 apps/frontend/src/modules/auth/pages/ResetPassword.tsx delete mode 100644 apps/frontend/src/modules/auth/pages/VerifyEmail.tsx delete mode 100644 apps/frontend/src/modules/backtesting/components/EquityCurveChart.tsx delete mode 100644 apps/frontend/src/modules/backtesting/components/PerformanceMetricsPanel.tsx delete mode 100644 apps/frontend/src/modules/backtesting/components/PredictionChart.tsx delete mode 100644 apps/frontend/src/modules/backtesting/components/StrategyComparisonChart.tsx delete mode 100644 apps/frontend/src/modules/backtesting/components/TradesTable.tsx delete mode 100644 apps/frontend/src/modules/backtesting/components/index.ts delete mode 100644 apps/frontend/src/modules/backtesting/pages/BacktestingDashboard.tsx delete mode 100644 apps/frontend/src/modules/dashboard/pages/Dashboard.tsx delete mode 100644 apps/frontend/src/modules/education/pages/CourseDetail.tsx delete mode 100644 apps/frontend/src/modules/education/pages/Courses.tsx delete mode 100644 apps/frontend/src/modules/investment/pages/Investment.tsx delete mode 100644 apps/frontend/src/modules/investment/pages/Portfolio.tsx delete mode 100644 apps/frontend/src/modules/investment/pages/Products.tsx delete mode 100644 apps/frontend/src/modules/ml/README.md delete mode 100644 apps/frontend/src/modules/ml/USAGE_EXAMPLES.md delete mode 100644 apps/frontend/src/modules/ml/VALIDATION_CHECKLIST.md delete mode 100644 apps/frontend/src/modules/ml/components/AMDPhaseIndicator.tsx delete mode 100644 apps/frontend/src/modules/ml/components/AccuracyMetrics.tsx delete mode 100644 apps/frontend/src/modules/ml/components/EnsembleSignalCard.tsx delete mode 100644 apps/frontend/src/modules/ml/components/ICTAnalysisCard.tsx delete mode 100644 apps/frontend/src/modules/ml/components/PredictionCard.tsx delete mode 100644 apps/frontend/src/modules/ml/components/SignalsTimeline.tsx delete mode 100644 apps/frontend/src/modules/ml/components/TradeExecutionModal.tsx delete mode 100644 apps/frontend/src/modules/ml/components/index.ts delete mode 100644 apps/frontend/src/modules/ml/pages/MLDashboard.tsx delete mode 100644 apps/frontend/src/modules/settings/pages/Settings.tsx delete mode 100644 apps/frontend/src/modules/trading/components/AccountSummary.tsx delete mode 100644 apps/frontend/src/modules/trading/components/AddSymbolModal.tsx delete mode 100644 apps/frontend/src/modules/trading/components/CandlestickChart.tsx delete mode 100644 apps/frontend/src/modules/trading/components/ChartToolbar.tsx delete mode 100644 apps/frontend/src/modules/trading/components/MLSignalsPanel.tsx delete mode 100644 apps/frontend/src/modules/trading/components/OrderForm.tsx delete mode 100644 apps/frontend/src/modules/trading/components/PaperTradingPanel.tsx delete mode 100644 apps/frontend/src/modules/trading/components/PositionsList.tsx delete mode 100644 apps/frontend/src/modules/trading/components/TradesHistory.tsx delete mode 100644 apps/frontend/src/modules/trading/components/TradingChart.tsx delete mode 100644 apps/frontend/src/modules/trading/components/WatchlistItem.tsx delete mode 100644 apps/frontend/src/modules/trading/components/WatchlistSidebar.tsx delete mode 100644 apps/frontend/src/modules/trading/pages/Trading.tsx delete mode 100644 apps/frontend/src/services/adminService.ts delete mode 100644 apps/frontend/src/services/backtestService.ts delete mode 100644 apps/frontend/src/services/chat.service.ts delete mode 100644 apps/frontend/src/services/mlService.ts delete mode 100644 apps/frontend/src/services/trading.service.ts delete mode 100644 apps/frontend/src/services/websocket.service.ts delete mode 100644 apps/frontend/src/stores/chatStore.ts delete mode 100644 apps/frontend/src/stores/tradingStore.ts delete mode 100644 apps/frontend/src/styles/index.css delete mode 100644 apps/frontend/src/types/chat.types.ts delete mode 100644 apps/frontend/src/types/trading.types.ts delete mode 100644 apps/frontend/src/vite-env.d.ts delete mode 100644 apps/frontend/tailwind.config.js delete mode 100644 apps/frontend/tsconfig.json delete mode 100644 apps/frontend/tsconfig.node.json delete mode 100644 apps/frontend/vite.config.ts delete mode 100644 apps/llm-agent/.env.example delete mode 100644 apps/llm-agent/AUTO_TRADING.md delete mode 100644 apps/llm-agent/DEPLOYMENT.md delete mode 100644 apps/llm-agent/Dockerfile delete mode 100644 apps/llm-agent/IMPLEMENTATION_SUMMARY.md delete mode 100644 apps/llm-agent/README.md delete mode 100644 apps/llm-agent/docker-compose.ollama.yml delete mode 100644 apps/llm-agent/environment.yml delete mode 100755 apps/llm-agent/examples/auto_trading_example.py delete mode 100644 apps/llm-agent/pyproject.toml delete mode 100644 apps/llm-agent/requirements.txt delete mode 100644 apps/llm-agent/src/__init__.py delete mode 100644 apps/llm-agent/src/api/__init__.py delete mode 100644 apps/llm-agent/src/api/auto_trade_routes.py delete mode 100644 apps/llm-agent/src/api/routes.py delete mode 100644 apps/llm-agent/src/clients/__init__.py delete mode 100644 apps/llm-agent/src/clients/mt4_client.py delete mode 100644 apps/llm-agent/src/config.py delete mode 100644 apps/llm-agent/src/core/__init__.py delete mode 100644 apps/llm-agent/src/core/context_manager.py delete mode 100644 apps/llm-agent/src/core/llm_client.py delete mode 100644 apps/llm-agent/src/core/prompt_manager.py delete mode 100644 apps/llm-agent/src/main.py delete mode 100644 apps/llm-agent/src/models/__init__.py delete mode 100644 apps/llm-agent/src/models/auto_trade.py delete mode 100644 apps/llm-agent/src/prompts/analysis.txt delete mode 100644 apps/llm-agent/src/prompts/strategy.txt delete mode 100644 apps/llm-agent/src/prompts/system.txt delete mode 100644 apps/llm-agent/src/prompts/trade_execution.txt delete mode 100644 apps/llm-agent/src/repositories/__init__.py delete mode 100644 apps/llm-agent/src/services/__init__.py delete mode 100644 apps/llm-agent/src/services/auto_trade_service.py delete mode 100644 apps/llm-agent/src/tools/__init__.py delete mode 100644 apps/llm-agent/src/tools/auto_trading.py delete mode 100644 apps/llm-agent/src/tools/base.py delete mode 100644 apps/llm-agent/src/tools/education.py delete mode 100644 apps/llm-agent/src/tools/ml_tools.py delete mode 100644 apps/llm-agent/src/tools/mt4_tools.py delete mode 100644 apps/llm-agent/src/tools/portfolio.py delete mode 100644 apps/llm-agent/src/tools/signals.py delete mode 100644 apps/llm-agent/src/tools/trading.py delete mode 100644 apps/llm-agent/tests/__init__.py delete mode 100644 apps/llm-agent/tests/conftest.py delete mode 100644 apps/llm-agent/tests/test_auto_trading.py delete mode 100644 apps/llm-agent/tests/test_mt4_integration.py delete mode 100644 apps/ml-engine/.env.example delete mode 100644 apps/ml-engine/Dockerfile delete mode 100644 apps/ml-engine/MIGRATION_REPORT.md delete mode 100644 apps/ml-engine/config/database.yaml delete mode 100644 apps/ml-engine/config/models.yaml delete mode 100644 apps/ml-engine/config/phase2.yaml delete mode 100644 apps/ml-engine/config/trading.yaml delete mode 100644 apps/ml-engine/environment.yml delete mode 100644 apps/ml-engine/pytest.ini delete mode 100644 apps/ml-engine/requirements.txt delete mode 100644 apps/ml-engine/src/__init__.py delete mode 100644 apps/ml-engine/src/api/__init__.py delete mode 100644 apps/ml-engine/src/api/main.py delete mode 100644 apps/ml-engine/src/backtesting/__init__.py delete mode 100644 apps/ml-engine/src/backtesting/engine.py delete mode 100644 apps/ml-engine/src/backtesting/metrics.py delete mode 100644 apps/ml-engine/src/backtesting/rr_backtester.py delete mode 100644 apps/ml-engine/src/data/__init__.py delete mode 100644 apps/ml-engine/src/data/data_service_client.py delete mode 100644 apps/ml-engine/src/data/database.py delete mode 100644 apps/ml-engine/src/data/features.py delete mode 100644 apps/ml-engine/src/data/indicators.py delete mode 100644 apps/ml-engine/src/data/pipeline.py delete mode 100644 apps/ml-engine/src/data/targets.py delete mode 100644 apps/ml-engine/src/data/validators.py delete mode 100644 apps/ml-engine/src/models/__init__.py delete mode 100644 apps/ml-engine/src/models/amd_detector.py delete mode 100644 apps/ml-engine/src/models/amd_models.py delete mode 100644 apps/ml-engine/src/models/ict_smc_detector.py delete mode 100644 apps/ml-engine/src/models/range_predictor.py delete mode 100644 apps/ml-engine/src/models/signal_generator.py delete mode 100644 apps/ml-engine/src/models/strategy_ensemble.py delete mode 100644 apps/ml-engine/src/models/tp_sl_classifier.py delete mode 100644 apps/ml-engine/src/pipelines/__init__.py delete mode 100644 apps/ml-engine/src/pipelines/phase2_pipeline.py delete mode 100644 apps/ml-engine/src/services/__init__.py delete mode 100644 apps/ml-engine/src/services/prediction_service.py delete mode 100644 apps/ml-engine/src/training/__init__.py delete mode 100644 apps/ml-engine/src/training/walk_forward.py delete mode 100644 apps/ml-engine/src/utils/__init__.py delete mode 100644 apps/ml-engine/src/utils/audit.py delete mode 100644 apps/ml-engine/src/utils/signal_logger.py delete mode 100644 apps/ml-engine/tests/__init__.py delete mode 100644 apps/ml-engine/tests/test_amd_detector.py delete mode 100644 apps/ml-engine/tests/test_api.py delete mode 100644 apps/ml-engine/tests/test_ict_detector.py delete mode 100644 apps/mt4-gateway/.env.example delete mode 100644 apps/mt4-gateway/config/agents.yml delete mode 100644 apps/mt4-gateway/requirements.txt delete mode 100644 apps/mt4-gateway/src/__init__.py delete mode 100644 apps/mt4-gateway/src/main.py delete mode 100644 apps/mt4-gateway/src/providers/__init__.py delete mode 100644 apps/mt4-gateway/src/providers/mt4_bridge_client.py delete mode 100644 apps/mt4-gateway/src/services/__init__.py delete mode 100644 apps/personal/.env.example delete mode 100644 apps/personal/config.yaml delete mode 100644 apps/personal/package.json delete mode 100644 apps/personal/scripts/setup-personal.ts delete mode 100644 apps/personal/scripts/validate-config.ts delete mode 100644 apps/trading-agents/.env.example delete mode 100644 apps/trading-agents/Dockerfile delete mode 100644 apps/trading-agents/IMPLEMENTATION_REPORT.md delete mode 100644 apps/trading-agents/INTEGRATION.md delete mode 100644 apps/trading-agents/PAPER_TRADING_GUIDE.md delete mode 100644 apps/trading-agents/README.md delete mode 100644 apps/trading-agents/config/agents.yaml delete mode 100644 apps/trading-agents/config/risk.yaml delete mode 100644 apps/trading-agents/config/strategies.yaml delete mode 100644 apps/trading-agents/docker-compose.yml delete mode 100644 apps/trading-agents/example_usage.py delete mode 100644 apps/trading-agents/requirements.txt delete mode 100644 apps/trading-agents/src/__init__.py delete mode 100644 apps/trading-agents/src/agents/__init__.py delete mode 100644 apps/trading-agents/src/agents/atlas.py delete mode 100644 apps/trading-agents/src/agents/base.py delete mode 100644 apps/trading-agents/src/agents/nova.py delete mode 100644 apps/trading-agents/src/agents/orion.py delete mode 100644 apps/trading-agents/src/api/main.py delete mode 100644 apps/trading-agents/src/exchange/__init__.py delete mode 100644 apps/trading-agents/src/exchange/binance_client.py delete mode 100644 apps/trading-agents/src/execution/__init__.py delete mode 100644 apps/trading-agents/src/execution/risk_manager.py delete mode 100644 apps/trading-agents/src/signals/__init__.py delete mode 100644 apps/trading-agents/src/signals/ml_consumer.py delete mode 100644 apps/trading-agents/src/strategies/__init__.py delete mode 100644 apps/trading-agents/src/strategies/base.py delete mode 100644 apps/trading-agents/src/strategies/grid_trading.py delete mode 100644 apps/trading-agents/src/strategies/mean_reversion.py delete mode 100644 apps/trading-agents/src/strategies/momentum.py delete mode 100644 apps/trading-agents/src/strategies/trend_following.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5f6636c --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# ============================================================================= +# SUBREPOSITORIOS - Tienen sus propios repositorios independientes +# Ver .gitmodules para referencias +# ============================================================================= +apps/backend/ +apps/frontend/ +apps/database/ +apps/ml-engine/ +apps/data-service/ + +# Apps adicionales (sin subrepo aún) +apps/llm-agent/ +apps/mt4-gateway/ +apps/personal/ +apps/trading-agents/ + +# Dependencias +node_modules/ + +# Build +dist/ +build/ +.next/ + +# Environment +.env +.env.local +!.env.example + +# Logs +*.log + +# IDE +.idea/ +.vscode/ + +# OS +.DS_Store + +# Python +__pycache__/ +*.pyc +.venv/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..7062cba --- /dev/null +++ b/.gitmodules @@ -0,0 +1,24 @@ +# ============================================================================= +# Subrepositorios de trading-platform +# Cada subproyecto tiene su propio repositorio para deployment independiente +# ============================================================================= + +[submodule "apps/backend"] + path = apps/backend + url = git@gitea-server:rckrdmrd/trading-platform-backend.git + +[submodule "apps/frontend"] + path = apps/frontend + url = git@gitea-server:rckrdmrd/trading-platform-frontend.git + +[submodule "apps/database"] + path = apps/database + url = git@gitea-server:rckrdmrd/trading-platform-database.git + +[submodule "apps/ml-engine"] + path = apps/ml-engine + url = git@gitea-server:rckrdmrd/trading-platform-ml-engine.git + +[submodule "apps/data-service"] + path = apps/data-service + url = git@gitea-server:rckrdmrd/trading-platform-data-service.git diff --git a/apps/backend/.env.example b/apps/backend/.env.example deleted file mode 100644 index 5a4c7db..0000000 --- a/apps/backend/.env.example +++ /dev/null @@ -1,159 +0,0 @@ -# OrbiQuant IA - Backend Environment Variables - -# ============================================================================ -# App -# ============================================================================ -NODE_ENV=development -PORT=3081 -FRONTEND_URL=http://localhost:3080 -API_URL=http://localhost:3081 - -# ============================================================================ -# CORS -# ============================================================================ -CORS_ORIGINS=http://localhost:3080,http://localhost:3081 - -# ============================================================================ -# JWT -# ============================================================================ -JWT_ACCESS_SECRET=your-access-secret-change-in-production-min-32-chars -JWT_REFRESH_SECRET=your-refresh-secret-change-in-production-min-32-chars -JWT_ACCESS_EXPIRES=15m -JWT_REFRESH_EXPIRES=7d - -# ============================================================================ -# Database (PostgreSQL) -# ============================================================================ -DB_HOST=localhost -DB_PORT=5432 -DB_NAME=orbiquant_platform -DB_USER=orbiquant_user -DB_PASSWORD=your-secure-password-here -DB_SSL=false -DB_POOL_MAX=20 -DB_IDLE_TIMEOUT=30000 -DB_CONNECTION_TIMEOUT=5000 - -# ============================================================================ -# Redis -# ============================================================================ -REDIS_HOST=localhost -REDIS_PORT=6379 -REDIS_PASSWORD= - -# ============================================================================ -# Stripe -# ============================================================================ -STRIPE_SECRET_KEY=sk_test_... -STRIPE_PUBLISHABLE_KEY=pk_test_... -STRIPE_WEBHOOK_SECRET=whsec_... - -# ============================================================================ -# ML Engine -# ============================================================================ -ML_ENGINE_URL=http://localhost:3083 -ML_ENGINE_API_KEY= -ML_ENGINE_TIMEOUT=30000 - -# ============================================================================ -# Trading Agents -# ============================================================================ -TRADING_AGENTS_URL=http://localhost:3086 -TRADING_AGENTS_TIMEOUT=60000 - -# ============================================================================ -# LLM Agent (Local Python Service) -# ============================================================================ -LLM_AGENT_URL=http://localhost:3085 -LLM_AGENT_TIMEOUT=120000 - -# ============================================================================ -# LLM Services (Cloud APIs - Fallback) -# ============================================================================ -# Anthropic (Claude) -ANTHROPIC_API_KEY=sk-ant-... - -# OpenAI (optional, fallback) -OPENAI_API_KEY=sk-... - -# LLM Configuration -LLM_PROVIDER=anthropic -LLM_MODEL=claude-3-5-sonnet-20241022 -LLM_MAX_TOKENS=4096 -LLM_TEMPERATURE=0.7 - -# ============================================================================ -# Binance API (Market Data) -# ============================================================================ -BINANCE_API_KEY= -BINANCE_SECRET_KEY= -BINANCE_TESTNET=true - -# ============================================================================ -# Rate Limiting -# ============================================================================ -RATE_LIMIT_WINDOW_MS=60000 -RATE_LIMIT_MAX=100 - -# ============================================================================ -# Email (SMTP) -# ============================================================================ -EMAIL_HOST=smtp.gmail.com -EMAIL_PORT=587 -EMAIL_SECURE=false -EMAIL_USER=your-email@gmail.com -EMAIL_PASSWORD=your-app-password -EMAIL_FROM=noreply@orbiquant.io - -# ============================================================================ -# Twilio (SMS/WhatsApp) -# ============================================================================ -TWILIO_ACCOUNT_SID=your-twilio-account-sid -TWILIO_AUTH_TOKEN=your-twilio-auth-token -TWILIO_PHONE_NUMBER=+1234567890 -TWILIO_WHATSAPP_NUMBER=+14155238886 -TWILIO_VERIFY_SERVICE_SID=your-verify-service-sid -TWILIO_USE_VERIFY_SERVICE=true - -# ============================================================================ -# OAuth - Google -# ============================================================================ -GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com -GOOGLE_CLIENT_SECRET=your-google-client-secret -GOOGLE_CALLBACK_URL=http://localhost:3081/api/v1/auth/google/callback - -# ============================================================================ -# OAuth - Facebook -# ============================================================================ -FACEBOOK_CLIENT_ID=your-facebook-app-id -FACEBOOK_CLIENT_SECRET=your-facebook-app-secret -FACEBOOK_CALLBACK_URL=http://localhost:3081/api/v1/auth/facebook/callback - -# ============================================================================ -# OAuth - Twitter/X -# ============================================================================ -TWITTER_CLIENT_ID=your-twitter-client-id -TWITTER_CLIENT_SECRET=your-twitter-client-secret -TWITTER_CALLBACK_URL=http://localhost:3081/api/v1/auth/twitter/callback - -# ============================================================================ -# OAuth - Apple Sign In -# ============================================================================ -APPLE_CLIENT_ID=your-apple-service-id -APPLE_CLIENT_SECRET=your-apple-client-secret -APPLE_TEAM_ID=your-apple-team-id -APPLE_KEY_ID=your-apple-key-id -APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----" -APPLE_CALLBACK_URL=http://localhost:3081/api/v1/auth/apple/callback - -# ============================================================================ -# OAuth - GitHub -# ============================================================================ -GITHUB_CLIENT_ID=your-github-client-id -GITHUB_CLIENT_SECRET=your-github-client-secret -GITHUB_CALLBACK_URL=http://localhost:3081/api/v1/auth/github/callback - -# ============================================================================ -# Logging -# ============================================================================ -LOG_LEVEL=info diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile deleted file mode 100644 index 079d239..0000000 --- a/apps/backend/Dockerfile +++ /dev/null @@ -1,73 +0,0 @@ -# ============================================================================= -# OrbiQuant IA - Backend API -# Multi-stage Dockerfile for production deployment -# ============================================================================= - -# ----------------------------------------------------------------------------- -# Stage 1: Dependencies -# ----------------------------------------------------------------------------- -FROM node:20-alpine AS deps - -WORKDIR /app - -# Install dependencies for native modules -RUN apk add --no-cache libc6-compat python3 make g++ - -# Copy package files -COPY package*.json ./ - -# Install all dependencies (including dev for build) -RUN npm ci - -# ----------------------------------------------------------------------------- -# Stage 2: Builder -# ----------------------------------------------------------------------------- -FROM node:20-alpine AS builder - -WORKDIR /app - -# Copy dependencies from deps stage -COPY --from=deps /app/node_modules ./node_modules -COPY . . - -# Build TypeScript -RUN npm run build - -# Remove dev dependencies -RUN npm prune --production - -# ----------------------------------------------------------------------------- -# Stage 3: Production -# ----------------------------------------------------------------------------- -FROM node:20-alpine AS runner - -WORKDIR /app - -# Create non-root user for security -RUN addgroup --system --gid 1001 nodejs -RUN adduser --system --uid 1001 orbiquant - -# Set production environment -ENV NODE_ENV=production -ENV PORT=3000 - -# Copy necessary files -COPY --from=builder /app/dist ./dist -COPY --from=builder /app/node_modules ./node_modules -COPY --from=builder /app/package.json ./package.json - -# Change ownership -RUN chown -R orbiquant:nodejs /app - -# Switch to non-root user -USER orbiquant - -# Expose port -EXPOSE 3000 - -# Health check -HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1 - -# Start application -CMD ["node", "dist/index.js"] diff --git a/apps/backend/WEBSOCKET_IMPLEMENTATION_REPORT.md b/apps/backend/WEBSOCKET_IMPLEMENTATION_REPORT.md deleted file mode 100644 index c3e9492..0000000 --- a/apps/backend/WEBSOCKET_IMPLEMENTATION_REPORT.md +++ /dev/null @@ -1,563 +0,0 @@ -# WebSocket Implementation Report - OrbiQuant IA - -**Fecha:** 2024-12-07 -**Épica:** OQI-003 - Trading y Charts -**Tarea:** Implementar WebSocket Server para actualizaciones en tiempo real -**Estado:** ✅ COMPLETADO - ---- - -## 1. RESUMEN EJECUTIVO - -Se ha implementado exitosamente un sistema de WebSocket para actualizaciones de precios en tiempo real, integrando directamente con los streams de Binance WebSocket. El sistema reemplaza el polling por verdaderas actualizaciones en tiempo real mediante event-driven architecture. - -### Mejoras Clave -- ✅ **Streaming en tiempo real** desde Binance (no polling) -- ✅ **Múltiples canales** de suscripción (price, ticker, klines, trades, depth) -- ✅ **Heartbeat/ping-pong** para mantener conexiones -- ✅ **Reconexión automática** en caso de desconexión -- ✅ **Cache de precios** para respuestas inmediatas -- ✅ **Gestión de memoria** (cleanup automático de clientes desconectados) - ---- - -## 2. ARCHIVOS MODIFICADOS - -### 2.1 `/apps/backend/src/core/websocket/trading-stream.service.ts` - -**Cambios principales:** - -1. **Integración directa con Binance WebSocket** - - Reemplazó polling (`setInterval`) por event listeners de Binance - - Agregó métodos: `startTickerStream()`, `startKlineStream()`, `startTradeStream()`, `startDepthStream()` - - Implementó manejo de eventos: `ticker`, `kline`, `trade`, `depth` - -2. **Nuevos tipos de datos** - ```typescript - export interface KlineData { - symbol: string; - interval: string; - time: number; - open: number; - high: number; - low: number; - close: number; - volume: number; - isFinal: boolean; - timestamp: Date; - } - ``` - -3. **Nuevos canales soportados** - - `price:` - Actualizaciones de precio - - `ticker:` - Estadísticas 24h completas - - `klines::` - Datos de velas - - `trades:` - Trades individuales - - `depth:` - Order book depth - -4. **Cache de precios** - - `priceCache: Map` para respuestas instantáneas - - TTL de 5 segundos - -5. **Referencias de streams de Binance** - - `binanceStreamRefs: Map` para rastrear suscripciones activas - - Cleanup automático cuando no hay subscriptores - -6. **Estadísticas mejoradas** - ```typescript - getStats(): { - connectedClients: number; - activeChannels: string[]; - quoteStreams: number; - signalStreams: number; - binanceStreams: number; // NUEVO - binanceActiveStreams: string[]; // NUEVO - priceCache: number; // NUEVO - } - ``` - -### 2.2 `/apps/backend/src/core/websocket/index.ts` - -**Cambio:** -- Exportó el nuevo tipo `KlineData` para uso en otros módulos - -### 2.3 Archivos NO modificados (ya existían) - -- `/apps/backend/src/core/websocket/websocket.server.ts` - Infraestructura base -- `/apps/backend/src/modules/trading/services/binance.service.ts` - Cliente de Binance -- `/apps/backend/src/index.ts` - Entry point (ya tenía WebSocket configurado) - ---- - -## 3. ARCHIVOS CREADOS - -### 3.1 Documentación - -**`/apps/backend/WEBSOCKET_TESTING.md`** (13 KB) -- Guía completa de uso del WebSocket -- Ejemplos de todos los tipos de mensajes -- Tutoriales para diferentes clientes (wscat, websocat, browser, Python) -- Troubleshooting y mejores prácticas - -### 3.2 Scripts de Testing - -**`/apps/backend/test-websocket.js`** (4.3 KB) -- Cliente de prueba en Node.js -- Auto-subscribe a múltiples canales -- Output formateado y colorizado -- Auto-disconnect después de 60s - -**`/apps/backend/test-websocket.html`** (14 KB) -- Dashboard interactivo en HTML -- UI visual para probar WebSocket -- Estadísticas en tiempo real -- Suscripción dinámica a canales - ---- - -## 4. DEPENDENCIAS - -### Instaladas (ya existían en package.json) -- ✅ `ws@8.18.0` - WebSocket library -- ✅ `@types/ws@8.5.13` - TypeScript types - -### No se requirieron nuevas dependencias - ---- - -## 5. ARQUITECTURA DEL SISTEMA - -``` -┌─────────────────────────────────────────────────────────────┐ -│ Frontend Client │ -│ (Browser/Mobile App) │ -└──────────────────────┬──────────────────────────────────────┘ - │ ws://localhost:3000/ws - │ -┌──────────────────────▼──────────────────────────────────────┐ -│ OrbiQuant WebSocket Server │ -│ (websocket.server.ts) │ -│ ┌──────────────────────────────────────────────────────┐ │ -│ │ - Manejo de conexiones │ │ -│ │ - Autenticación (opcional) │ │ -│ │ - Channel subscriptions │ │ -│ │ - Heartbeat/ping-pong │ │ -│ │ - Broadcast a clientes suscritos │ │ -│ └──────────────────┬───────────────────────────────────┘ │ -└─────────────────────┼──────────────────────────────────────┘ - │ -┌─────────────────────▼──────────────────────────────────────┐ -│ Trading Stream Service │ -│ (trading-stream.service.ts) │ -│ ┌──────────────────────────────────────────────────────┐ │ -│ │ - Gestión de subscripciones por canal │ │ -│ │ - Event listeners de Binance │ │ -│ │ - Cache de precios │ │ -│ │ - Transformación de datos │ │ -│ │ - Broadcast a clientes │ │ -│ └──────────────────┬───────────────────────────────────┘ │ -└─────────────────────┼──────────────────────────────────────┘ - │ -┌─────────────────────▼──────────────────────────────────────┐ -│ Binance Service │ -│ (binance.service.ts) │ -│ ┌──────────────────────────────────────────────────────┐ │ -│ │ - WebSocket client para Binance │ │ -│ │ - Subscripción a streams: │ │ -│ │ • ticker (@ticker) │ │ -│ │ • klines (@kline_) │ │ -│ │ • trades (@trade) │ │ -│ │ • depth (@depth10@100ms) │ │ -│ │ - Reconexión automática │ │ -│ │ - Event emitter │ │ -│ └──────────────────┬───────────────────────────────────┘ │ -└─────────────────────┼──────────────────────────────────────┘ - │ - ┌────────────▼────────────┐ - │ Binance WebSocket │ - │ wss://stream.binance │ - │ .com:9443/ws │ - └─────────────────────────┘ -``` - ---- - -## 6. FLUJO DE DATOS - -### 6.1 Cliente Subscribe a un Canal - -``` -1. Cliente → Server: {"type":"subscribe","channels":["price:BTCUSDT"]} -2. Server → tradingStreamService.handleSubscribe("price:BTCUSDT") -3. tradingStreamService → binanceService.subscribeTicker("BTCUSDT") -4. binanceService → Binance WS: Conecta a "btcusdt@ticker" -5. Server → Cliente: {"type":"subscribed","channel":"price:BTCUSDT"} -``` - -### 6.2 Recepción de Datos en Tiempo Real - -``` -1. Binance WS → binanceService: Ticker data -2. binanceService → EventEmitter.emit('ticker', data) -3. tradingStreamService: listener('ticker') recibe data -4. tradingStreamService: Transforma data a QuoteData -5. tradingStreamService: Actualiza priceCache -6. tradingStreamService → wsManager.broadcast("price:BTCUSDT", {...}) -7. wsManager → Todos los clientes suscritos a "price:BTCUSDT" -``` - -### 6.3 Cleanup al Desuscribirse - -``` -1. Cliente → Server: {"type":"unsubscribe","channels":["price:BTCUSDT"]} -2. Server → tradingStreamService.handleUnsubscribe() -3. tradingStreamService: Verifica si hay otros suscritos -4. Si no hay suscritos → binanceService.unsubscribe("btcusdt@ticker") -5. binanceService: Cierra conexión WS con Binance -6. tradingStreamService: Limpia binanceStreamRefs -7. Server → Cliente: {"type":"unsubscribed","channel":"price:BTCUSDT"} -``` - ---- - -## 7. RESULTADOS DE BUILD Y TESTS - -### 7.1 TypeScript Build -```bash -$ npm run build -> tsc -✅ Build exitoso - 0 errores -``` - -### 7.2 Type Checking -```bash -$ npm run typecheck -> tsc --noEmit -✅ Type checking exitoso - 0 errores -``` - -### 7.3 ESLint -``` -⚠️ No se encontró configuración de ESLint -Nota: Esto no afecta la funcionalidad. Se puede configurar posteriormente. -``` - ---- - -## 8. CÓMO PROBAR EL WEBSOCKET - -### 8.1 Iniciar el Backend - -```bash -cd /home/isem/workspace/projects/trading-platform/apps/backend -npm run dev -``` - -### 8.2 Opción 1: Script Node.js (Consola) - -```bash -node test-websocket.js -``` - -**Output esperado:** -``` -OrbiQuant WebSocket Test Client -================================ - -⏳ Connecting to WebSocket server... - (Test will run for 60 seconds, or press Ctrl+C to stop) - -✅ Connected to WebSocket server - URL: ws://localhost:3000/ws - -📡 Subscribing to channels... - -🔌 Server welcome message: - Client ID: ws_1701806400000_abc123def - Authenticated: false - Timestamp: 2024-12-06T12:00:00.000Z - -✅ Subscribed to: price:BTCUSDT -✅ Subscribed to: ticker:ETHUSDT -✅ Subscribed to: klines:BTCUSDT:1m - -[2.3s] 💰 PRICE UPDATE - BTCUSDT - Price: $97,523.45 - 24h Change: +2.47% - Volume: 12,345.67 - -[2.8s] 📊 TICKER UPDATE - ETHUSDT - Price: $3,650.00 - Bid/Ask: $3,649.50 / $3,650.50 - 24h: +3.56% - High/Low: $3,700.00 / $3,500.00 - -[3.1s] 📈 KLINE UPDATE - BTCUSDT (1m) - O: $97500.0 H: $97600.0 L: $97400.0 C: $97523.45 - Volume: 123.4500 - Status: ⏳ Updating -``` - -### 8.3 Opción 2: Dashboard HTML (Browser) - -1. Abrir en navegador: `test-websocket.html` -2. Click en "Connect" -3. Suscribirse a canales desde la UI - -**Características:** -- ✅ UI visual interactiva -- ✅ Estadísticas en tiempo real -- ✅ Suscripción dinámica -- ✅ Log coloreado de mensajes - -### 8.4 Opción 3: wscat - -```bash -npm install -g wscat -wscat -c ws://localhost:3000/ws - -> {"type":"subscribe","channels":["price:BTCUSDT"]} -< {"type":"subscribed","channel":"price:BTCUSDT","timestamp":"..."} -< {"type":"price","channel":"price:BTCUSDT","data":{...}} -``` - -### 8.5 Verificar Estadísticas del Servidor - -```bash -curl http://localhost:3000/api/v1/ws/stats -``` - -**Response:** -```json -{ - "success": true, - "data": { - "connectedClients": 2, - "activeChannels": ["price:BTCUSDT", "klines:ETHUSDT:1m"], - "quoteStreams": 0, - "signalStreams": 0, - "binanceStreams": 2, - "binanceActiveStreams": ["btcusdt@ticker", "ethusdt@kline_1m"], - "priceCache": 2 - } -} -``` - ---- - -## 9. EJEMPLOS DE MENSAJES - -### 9.1 Price Update (del spec) - -```json -{ - "type": "price", - "symbol": "BTCUSDT", - "data": { - "price": 97523.45, - "change24h": 2345.67, - "changePercent24h": 2.47, - "high24h": 98500.00, - "low24h": 95000.00, - "volume24h": 12345.67, - "timestamp": 1701806400000 - } -} -``` - -### 9.2 Kline Update (del spec) - -```json -{ - "type": "kline", - "symbol": "BTCUSDT", - "interval": "1m", - "data": { - "time": 1701806400, - "open": 97500, - "high": 97600, - "low": 97400, - "close": 97523.45, - "volume": 123.45 - } -} -``` - -### 9.3 Pong Response (del spec) - -```json -{ - "type": "pong", - "timestamp": 1701806400000 -} -``` - ---- - -## 10. CRITERIOS DE ACEPTACIÓN - -| Criterio | Estado | Notas | -|----------|--------|-------| -| WebSocket server escucha en `/ws` | ✅ CUMPLIDO | Configurado en `index.ts` | -| Clientes pueden suscribirse a precios de símbolos | ✅ CUMPLIDO | Canales: price, ticker, klines | -| Updates de precio se envían cada 1-2 segundos | ✅ CUMPLIDO | En tiempo real desde Binance | -| Heartbeat/ping-pong funciona | ✅ CUMPLIDO | Implementado en `websocket.server.ts` | -| Sin memory leaks (cleanup de clientes desconectados) | ✅ CUMPLIDO | Cleanup automático en `handleDisconnect()` | -| `npm run build` pasa sin errores | ✅ CUMPLIDO | Build exitoso | -| `npm run lint` pasa o solo warnings no críticos | ⚠️ PARCIAL | No hay config de ESLint (no crítico) | - ---- - -## 11. PROBLEMAS ENCONTRADOS Y SOLUCIONES - -### 11.1 Problema: Sistema ya tenía WebSocket implementado - -**Solución:** -- No se crearon archivos nuevos desde cero -- Se mejoró el sistema existente agregando integración directa con Binance -- Se mantuvieron interfaces compatibles con el código existente - -### 11.2 Problema: ESLint no configurado - -**Solución:** -- No es crítico para funcionalidad -- TypeScript compiler y `tsc --noEmit` proporcionan validación suficiente -- Se puede configurar ESLint posteriormente si es necesario - -### 11.3 Problema: Múltiples canales para el mismo propósito - -**Solución:** -- Se mantuvieron canales compatibles hacia atrás (`quotes`) -- Se agregaron nuevos canales específicos (`price`, `ticker`, `klines`) -- Todos usan los mismos streams de Binance internamente - ---- - -## 12. VENTAJAS DE LA IMPLEMENTACIÓN - -### 12.1 Rendimiento -- ✅ **Latencia reducida**: Datos directos de Binance sin polling -- ✅ **Menos carga en servidor**: Event-driven vs polling cada 1s -- ✅ **Escalable**: Un stream de Binance sirve a múltiples clientes - -### 12.2 Confiabilidad -- ✅ **Reconexión automática**: Binance service maneja desconexiones -- ✅ **Heartbeat**: Detecta conexiones muertas (30s interval) -- ✅ **Error handling**: Fallback a datos mock si Binance falla - -### 12.3 Funcionalidad -- ✅ **Múltiples tipos de datos**: Price, ticker, klines, trades, depth -- ✅ **Múltiples intervalos**: Klines soporta 14 intervalos diferentes -- ✅ **Cache inteligente**: Respuestas inmediatas en nueva suscripción - -### 12.4 Mantenibilidad -- ✅ **Código organizado**: Separación clara de responsabilidades -- ✅ **TypeScript**: Type safety completo -- ✅ **Documentación**: Guías completas y ejemplos -- ✅ **Testing**: Scripts de prueba incluidos - ---- - -## 13. SIGUIENTES PASOS RECOMENDADOS - -### 13.1 Integración Frontend -```typescript -// En el frontend (React/Vue/Angular) -const ws = new WebSocket('ws://localhost:3000/ws'); - -ws.onopen = () => { - ws.send(JSON.stringify({ - type: 'subscribe', - channels: ['price:BTCUSDT', 'klines:ETHUSDT:5m'] - })); -}; - -ws.onmessage = (event) => { - const msg = JSON.parse(event.data); - - if (msg.type === 'price') { - updatePriceDisplay(msg.data); - } else if (msg.type === 'kline') { - updateChart(msg.data); - } -}; -``` - -### 13.2 Autenticación para Canales Privados -- Implementar JWT en query string: `ws://localhost:3000/ws?token=` -- Ya soportado en `websocket.server.ts` (línea 82-92) -- Canales privados: `portfolio:`, `orders:`, `account:` - -### 13.3 Rate Limiting -- Limitar número de subscripciones por cliente (ya hay `MAX_SYMBOLS_PER_CLIENT = 50`) -- Limitar frecuencia de mensajes - -### 13.4 Monitoring -- Agregar métricas de Prometheus -- Dashboard de Grafana para WebSocket stats -- Alertas por desconexiones frecuentes - ---- - -## 14. RECURSOS Y REFERENCIAS - -### Documentación Creada -- `/apps/backend/WEBSOCKET_TESTING.md` - Guía completa de uso -- `/apps/backend/WEBSOCKET_IMPLEMENTATION_REPORT.md` - Este documento - -### Scripts de Testing -- `/apps/backend/test-websocket.js` - Cliente CLI -- `/apps/backend/test-websocket.html` - Dashboard web - -### Código Fuente Modificado -- `/apps/backend/src/core/websocket/trading-stream.service.ts` -- `/apps/backend/src/core/websocket/index.ts` - -### APIs Externas -- Binance WebSocket Streams: https://binance-docs.github.io/apidocs/spot/en/#websocket-market-streams -- Binance API Documentation: https://binance-docs.github.io/apidocs/ - ---- - -## 15. CONTACTO Y SOPORTE - -**Para problemas o preguntas:** - -1. Revisar logs del backend: `npm run dev` (muestra logs en tiempo real) -2. Verificar estadísticas: `GET /api/v1/ws/stats` -3. Verificar salud del servidor: `GET /health` -4. Consultar `WEBSOCKET_TESTING.md` para troubleshooting - -**Logs importantes a revisar:** -- `[WS]` - WebSocket server events -- `[TradingStream]` - Trading stream service events -- `[Binance WS]` - Binance WebSocket events - ---- - -## 16. CONCLUSIÓN - -✅ **Estado: COMPLETADO** - -Se ha implementado exitosamente un sistema completo de WebSocket para actualizaciones en tiempo real, superando los requisitos originales: - -**Requerimientos Originales:** -- ✅ WebSocket server para enviar actualizaciones de precios -- ✅ Soporte para canales de suscripción -- ✅ Integración con Binance WebSocket - -**Extras Implementados:** -- ✅ Múltiples tipos de canales (price, ticker, klines, trades, depth) -- ✅ Cache de precios para respuestas inmediatas -- ✅ Documentación completa y scripts de testing -- ✅ Dashboard web interactivo -- ✅ Estadísticas en tiempo real -- ✅ Manejo robusto de errores y reconexión - -El sistema está listo para producción y puede escalar para soportar cientos de conexiones simultáneas. - ---- - -**Implementado por:** Backend-Agent (Claude Code) -**Fecha de finalización:** 2024-12-07 -**Versión:** 1.0.0 diff --git a/apps/backend/WEBSOCKET_TESTING.md b/apps/backend/WEBSOCKET_TESTING.md deleted file mode 100644 index c95078b..0000000 --- a/apps/backend/WEBSOCKET_TESTING.md +++ /dev/null @@ -1,648 +0,0 @@ -# WebSocket Testing Guide - OrbiQuant Trading Platform - -## Overview - -The OrbiQuant backend now supports real-time market data via WebSocket, powered by direct integration with Binance WebSocket streams. This guide explains how to test and use the WebSocket API. - -## WebSocket Endpoint - -``` -ws://localhost:3000/ws -``` - -For production: -``` -wss://your-domain.com/ws -``` - -## Authentication (Optional) - -To access private channels (portfolio, orders, user-specific data), include a JWT token in the query string: - -``` -ws://localhost:3000/ws?token=YOUR_JWT_TOKEN -``` - -Public market data channels do not require authentication. - -## Message Format - -### Client to Server - -All messages must be valid JSON with a `type` field: - -```json -{ - "type": "subscribe", - "channels": ["price:BTCUSDT"] -} -``` - -### Server to Client - -Server responses include a `type`, optional `channel`, and `timestamp`: - -```json -{ - "type": "price", - "channel": "price:BTCUSDT", - "data": { - "symbol": "BTCUSDT", - "price": 97523.45, - "change24h": 2345.67, - "changePercent24h": 2.47, - "high24h": 98500.00, - "low24h": 95000.00, - "volume24h": 12345.67, - "timestamp": 1701806400000 - }, - "timestamp": "2024-12-06T12:00:00.000Z" -} -``` - -## Available Channels - -### 1. Price Updates (`price:`) - -Real-time price updates for a specific symbol (via Binance ticker stream). - -**Subscribe:** -```json -{ - "type": "subscribe", - "channels": ["price:BTCUSDT"] -} -``` - -**Data received:** -```json -{ - "type": "price", - "channel": "price:BTCUSDT", - "data": { - "symbol": "BTCUSDT", - "price": 97523.45, - "change24h": 2345.67, - "changePercent24h": 2.47, - "high24h": 98500.00, - "low24h": 95000.00, - "volume24h": 12345.67, - "timestamp": 1701806400000 - } -} -``` - -### 2. Ticker Updates (`ticker:`) - -Full 24h ticker statistics (bid, ask, volume, etc.). - -**Subscribe:** -```json -{ - "type": "subscribe", - "channels": ["ticker:ETHUSDT"] -} -``` - -**Data received:** -```json -{ - "type": "ticker", - "channel": "ticker:ETHUSDT", - "data": { - "symbol": "ETHUSDT", - "price": 3650.00, - "bid": 3649.50, - "ask": 3650.50, - "volume": 123456.78, - "change": 125.50, - "changePercent": 3.56, - "high": 3700.00, - "low": 3500.00, - "open": 3524.50, - "previousClose": 3524.50, - "timestamp": "2024-12-06T12:00:00.000Z" - } -} -``` - -### 3. Klines/Candlesticks (`klines::`) - -Real-time candlestick data at specified intervals. - -**Intervals:** `1m`, `3m`, `5m`, `15m`, `30m`, `1h`, `2h`, `4h`, `6h`, `12h`, `1d`, `3d`, `1w`, `1M` - -**Subscribe:** -```json -{ - "type": "subscribe", - "channels": ["klines:BTCUSDT:1m"] -} -``` - -**Data received:** -```json -{ - "type": "kline", - "channel": "klines:BTCUSDT:1m", - "data": { - "symbol": "BTCUSDT", - "interval": "1m", - "time": 1701806400000, - "open": 97500.00, - "high": 97600.00, - "low": 97400.00, - "close": 97523.45, - "volume": 123.45, - "isFinal": false, - "timestamp": "2024-12-06T12:00:00.000Z" - } -} -``` - -**Note:** `isFinal: true` indicates the candle is closed and will not change. - -### 4. Trades (`trades:`) - -Individual trade executions as they happen. - -**Subscribe:** -```json -{ - "type": "subscribe", - "channels": ["trades:BTCUSDT"] -} -``` - -**Data received:** -```json -{ - "type": "trade", - "channel": "trades:BTCUSDT", - "data": { - "symbol": "BTCUSDT", - "price": 97523.45, - "quantity": 0.5, - "side": "buy", - "timestamp": "2024-12-06T12:00:00.000Z" - } -} -``` - -### 5. Depth/Order Book (`depth:`) - -Order book depth updates (top 10 levels by default). - -**Subscribe:** -```json -{ - "type": "subscribe", - "channels": ["depth:BTCUSDT"] -} -``` - -**Data received:** -```json -{ - "type": "depth", - "channel": "depth:BTCUSDT", - "data": { - "symbol": "BTCUSDT", - "bids": [ - [97520.00, 1.5], - [97519.00, 2.3] - ], - "asks": [ - [97521.00, 0.8], - [97522.00, 1.2] - ], - "timestamp": "2024-12-06T12:00:00.000Z" - } -} -``` - -### 6. ML Signals (`signals:`) - -AI-powered trading signals (requires backend ML service). - -**Subscribe:** -```json -{ - "type": "subscribe", - "channels": ["signals:BTCUSDT"] -} -``` - -## Client Messages - -### Subscribe - -```json -{ - "type": "subscribe", - "channels": ["price:BTCUSDT", "ticker:ETHUSDT", "klines:SOLUSDT:5m"] -} -``` - -### Unsubscribe - -```json -{ - "type": "unsubscribe", - "channels": ["price:BTCUSDT"] -} -``` - -### Ping (Keepalive) - -```json -{ - "type": "ping" -} -``` - -**Response:** -```json -{ - "type": "pong", - "timestamp": "2024-12-06T12:00:00.000Z" -} -``` - -## Testing Tools - -### 1. Using `wscat` (Node.js) - -Install: -```bash -npm install -g wscat -``` - -Connect: -```bash -wscat -c ws://localhost:3000/ws -``` - -Send messages: -``` -> {"type":"subscribe","channels":["price:BTCUSDT"]} -< {"type":"subscribed","channel":"price:BTCUSDT","timestamp":"..."} -< {"type":"price","channel":"price:BTCUSDT","data":{...}} -``` - -### 2. Using `websocat` (Rust) - -Install: -```bash -# macOS -brew install websocat - -# Linux -cargo install websocat -``` - -Connect: -```bash -websocat ws://localhost:3000/ws -``` - -### 3. Using Browser JavaScript - -```html - - - - WebSocket Test - - -

OrbiQuant WebSocket Test

-
- - - - -``` - -### 4. Using Python - -```python -import asyncio -import websockets -import json - -async def test_websocket(): - uri = "ws://localhost:3000/ws" - async with websockets.connect(uri) as websocket: - # Subscribe to channels - await websocket.send(json.dumps({ - "type": "subscribe", - "channels": ["price:BTCUSDT", "klines:BTCUSDT:1m"] - })) - - # Listen for messages - while True: - message = await websocket.recv() - data = json.loads(message) - print(f"Received: {data['type']}") - if 'data' in data: - print(f" Data: {data['data']}") - -asyncio.run(test_websocket()) -``` - -## WebSocket Stats Endpoint - -Check WebSocket server statistics: - -```bash -curl http://localhost:3000/api/v1/ws/stats -``` - -**Response:** -```json -{ - "success": true, - "data": { - "connectedClients": 5, - "activeChannels": ["price:BTCUSDT", "klines:ETHUSDT:1m"], - "quoteStreams": 0, - "signalStreams": 2, - "binanceStreams": 3, - "binanceActiveStreams": ["btcusdt@ticker", "ethusdt@kline_1m"], - "priceCache": 2 - } -} -``` - -## Common Symbols - -- `BTCUSDT` - Bitcoin/USDT -- `ETHUSDT` - Ethereum/USDT -- `BNBUSDT` - Binance Coin/USDT -- `SOLUSDT` - Solana/USDT -- `XRPUSDT` - Ripple/USDT -- `DOGEUSDT` - Dogecoin/USDT -- `ADAUSDT` - Cardano/USDT -- `AVAXUSDT` - Avalanche/USDT - -## Error Handling - -### Connection Errors - -If connection fails, check: -1. Backend server is running on port 3000 -2. WebSocket path is `/ws` -3. No firewall blocking the connection - -### Subscription Errors - -```json -{ - "type": "error", - "channel": "price:INVALID", - "data": { - "message": "Failed to fetch quote for INVALID" - } -} -``` - -### Authentication Errors - -For private channels without valid token: -```json -{ - "type": "error", - "channel": "portfolio:user123", - "data": { - "message": "Authentication required for this channel" - } -} -``` - -## Best Practices - -1. **Heartbeat**: Send ping every 30 seconds to keep connection alive -2. **Reconnection**: Implement exponential backoff for reconnections -3. **Subscription Limit**: Don't subscribe to too many symbols at once (max 50 per client) -4. **Clean Disconnect**: Unsubscribe before closing connection -5. **Error Handling**: Always handle `error` messages from server - -## Example: Complete Trading Dashboard - -```javascript -class TradingDashboard { - constructor() { - this.ws = null; - this.subscriptions = new Set(); - this.reconnectAttempts = 0; - this.maxReconnectAttempts = 5; - } - - connect() { - this.ws = new WebSocket('ws://localhost:3000/ws'); - - this.ws.onopen = () => { - console.log('Connected to trading server'); - this.reconnectAttempts = 0; - this.resubscribe(); - }; - - this.ws.onmessage = (event) => { - const msg = JSON.parse(event.data); - this.handleMessage(msg); - }; - - this.ws.onclose = () => { - console.log('Disconnected from server'); - this.attemptReconnect(); - }; - - this.ws.onerror = (error) => { - console.error('WebSocket error:', error); - }; - - // Heartbeat - setInterval(() => this.ping(), 30000); - } - - subscribe(channel) { - this.subscriptions.add(channel); - if (this.ws?.readyState === WebSocket.OPEN) { - this.ws.send(JSON.stringify({ - type: 'subscribe', - channels: [channel] - })); - } - } - - unsubscribe(channel) { - this.subscriptions.delete(channel); - if (this.ws?.readyState === WebSocket.OPEN) { - this.ws.send(JSON.stringify({ - type: 'unsubscribe', - channels: [channel] - })); - } - } - - resubscribe() { - if (this.subscriptions.size > 0) { - this.ws.send(JSON.stringify({ - type: 'subscribe', - channels: Array.from(this.subscriptions) - })); - } - } - - ping() { - if (this.ws?.readyState === WebSocket.OPEN) { - this.ws.send(JSON.stringify({ type: 'ping' })); - } - } - - attemptReconnect() { - if (this.reconnectAttempts >= this.maxReconnectAttempts) { - console.error('Max reconnect attempts reached'); - return; - } - - const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000); - this.reconnectAttempts++; - - console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`); - setTimeout(() => this.connect(), delay); - } - - handleMessage(msg) { - switch (msg.type) { - case 'connected': - console.log('Server says: connected', msg.data); - break; - case 'price': - this.updatePrice(msg.data); - break; - case 'kline': - this.updateChart(msg.data); - break; - case 'ticker': - this.updateTicker(msg.data); - break; - case 'trade': - this.addTrade(msg.data); - break; - case 'error': - console.error('Server error:', msg.data); - break; - case 'pong': - // Heartbeat acknowledged - break; - default: - console.log('Unknown message type:', msg.type); - } - } - - updatePrice(data) { - console.log(`Price update: ${data.symbol} = $${data.price}`); - // Update UI - } - - updateChart(data) { - console.log(`Kline update: ${data.symbol} ${data.interval}`); - // Update chart - } - - updateTicker(data) { - console.log(`Ticker update: ${data.symbol}`); - // Update ticker display - } - - addTrade(data) { - console.log(`New trade: ${data.symbol} ${data.side} ${data.quantity} @ ${data.price}`); - // Add to trades list - } - - disconnect() { - if (this.ws) { - this.ws.close(); - this.ws = null; - } - } -} - -// Usage -const dashboard = new TradingDashboard(); -dashboard.connect(); -dashboard.subscribe('price:BTCUSDT'); -dashboard.subscribe('klines:ETHUSDT:1m'); -dashboard.subscribe('trades:SOLUSDT'); -``` - -## Troubleshooting - -### No Data Received - -1. Check if Binance API is accessible from your server -2. Verify symbol format (must be uppercase, e.g., `BTCUSDT`) -3. Check backend logs for connection errors - -### High Latency - -1. Ensure server is geographically close to Binance servers -2. Check network connection quality -3. Reduce number of subscriptions - -### Disconnections - -1. Implement heartbeat/ping mechanism -2. Check server logs for errors -3. Verify no rate limiting from Binance - -## Support - -For issues or questions: -- Check backend logs: `npm run dev` (shows real-time logs) -- WebSocket stats endpoint: `GET /api/v1/ws/stats` -- Server health: `GET /health` - ---- - -**Last Updated:** December 6, 2024 -**Backend Version:** 0.1.0 -**WebSocket Protocol:** RFC 6455 diff --git a/apps/backend/eslint.config.js b/apps/backend/eslint.config.js deleted file mode 100644 index c1af0a5..0000000 --- a/apps/backend/eslint.config.js +++ /dev/null @@ -1,29 +0,0 @@ -import eslint from '@eslint/js'; -import tseslint from 'typescript-eslint'; -import globals from 'globals'; - -export default tseslint.config( - eslint.configs.recommended, - ...tseslint.configs.recommended, - { - ignores: ['dist/**', 'node_modules/**', 'coverage/**'], - }, - { - files: ['**/*.ts'], - languageOptions: { - ecmaVersion: 2022, - sourceType: 'module', - globals: { - ...globals.node, - ...globals.jest, - }, - }, - rules: { - '@typescript-eslint/explicit-function-return-type': 'off', - '@typescript-eslint/explicit-module-boundary-types': 'off', - '@typescript-eslint/no-explicit-any': 'warn', - '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], - '@typescript-eslint/no-empty-function': 'off', - }, - } -); diff --git a/apps/backend/jest.config.ts b/apps/backend/jest.config.ts deleted file mode 100644 index 34206c2..0000000 --- a/apps/backend/jest.config.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { Config } from 'jest'; - -const config: Config = { - preset: 'ts-jest', - testEnvironment: 'node', - roots: ['/src'], - testMatch: [ - '**/__tests__/**/*.test.ts', - '**/__tests__/**/*.spec.ts', - '**/?(*.)+(spec|test).ts' - ], - testPathIgnorePatterns: [ - '/node_modules/', - '/__tests__/mocks/', - '/__tests__/setup.ts' - ], - moduleFileExtensions: ['ts', 'js', 'json'], - collectCoverageFrom: [ - 'src/**/*.ts', - '!src/**/*.d.ts', - '!src/**/*.spec.ts', - '!src/**/*.test.ts', - '!src/**/index.ts' - ], - coverageDirectory: 'coverage', - coverageReporters: ['text', 'lcov', 'html'], - verbose: true, - moduleNameMapper: { - '^@/(.*)$': '/src/$1' - }, - transform: { - '^.+\\.ts$': ['ts-jest', {}] - }, - setupFilesAfterEnv: ['/src/__tests__/setup.ts'] -}; - -export default config; diff --git a/apps/backend/package-lock.json b/apps/backend/package-lock.json deleted file mode 100644 index 53c4cfd..0000000 --- a/apps/backend/package-lock.json +++ /dev/null @@ -1,11171 +0,0 @@ -{ - "name": "@orbiquant/backend", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@orbiquant/backend", - "version": "0.1.0", - "dependencies": { - "@anthropic-ai/sdk": "^0.71.2", - "axios": "^1.6.2", - "bcryptjs": "^3.0.3", - "compression": "^1.7.4", - "cors": "^2.8.5", - "date-fns": "^4.1.0", - "dotenv": "^16.4.7", - "express": "^5.0.1", - "express-rate-limit": "^7.5.0", - "express-validator": "^7.0.1", - "google-auth-library": "^9.4.1", - "helmet": "^8.1.0", - "jsonwebtoken": "^9.0.2", - "morgan": "^1.10.0", - "nodemailer": "^7.0.11", - "openai": "^4.104.0", - "passport": "^0.7.0", - "passport-apple": "^2.0.2", - "passport-facebook": "^3.0.0", - "passport-github2": "^0.1.12", - "passport-google-oauth20": "^2.0.0", - "passport-local": "^1.0.0", - "pg": "^8.11.3", - "qrcode": "^1.5.3", - "speakeasy": "^2.0.0", - "stripe": "^17.5.0", - "swagger-jsdoc": "^6.2.8", - "swagger-ui-express": "^5.0.1", - "twilio": "^4.19.3", - "uuid": "^9.0.1", - "winston": "^3.11.0", - "ws": "^8.18.0", - "zod": "^3.22.4" - }, - "devDependencies": { - "@eslint/js": "^9.17.0", - "@types/bcryptjs": "^2.4.6", - "@types/compression": "^1.7.5", - "@types/cors": "^2.8.17", - "@types/express": "^5.0.0", - "@types/jest": "^30.0.0", - "@types/jsonwebtoken": "^9.0.5", - "@types/morgan": "^1.9.9", - "@types/node": "^20.10.4", - "@types/nodemailer": "^6.4.14", - "@types/passport": "^1.0.16", - "@types/passport-facebook": "^3.0.3", - "@types/passport-github2": "^1.2.9", - "@types/passport-google-oauth20": "^2.0.14", - "@types/passport-local": "^1.0.38", - "@types/pg": "^8.10.9", - "@types/qrcode": "^1.5.5", - "@types/speakeasy": "^2.0.10", - "@types/supertest": "^2.0.16", - "@types/swagger-jsdoc": "^6.0.4", - "@types/swagger-ui-express": "^4.1.8", - "@types/uuid": "^9.0.7", - "@types/ws": "^8.5.13", - "eslint": "^9.17.0", - "globals": "^15.14.0", - "jest": "^30.0.0", - "prettier": "^3.1.1", - "supertest": "^6.3.3", - "ts-jest": "^29.3.0", - "tsx": "^4.6.2", - "typescript": "^5.3.3", - "typescript-eslint": "^8.18.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@anthropic-ai/sdk": { - "version": "0.71.2", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.71.2.tgz", - "integrity": "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ==", - "license": "MIT", - "dependencies": { - "json-schema-to-ts": "^3.1.1" - }, - "bin": { - "anthropic-ai-sdk": "bin/cli" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" - }, - "peerDependenciesMeta": { - "zod": { - "optional": true - } - } - }, - "node_modules/@apidevtools/json-schema-ref-parser": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz", - "integrity": "sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==", - "license": "MIT", - "dependencies": { - "@jsdevtools/ono": "^7.1.3", - "@types/json-schema": "^7.0.6", - "call-me-maybe": "^1.0.1", - "js-yaml": "^4.1.0" - } - }, - "node_modules/@apidevtools/openapi-schemas": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", - "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/@apidevtools/swagger-methods": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", - "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==", - "license": "MIT" - }, - "node_modules/@apidevtools/swagger-parser": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz", - "integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==", - "license": "MIT", - "dependencies": { - "@apidevtools/json-schema-ref-parser": "^9.0.6", - "@apidevtools/openapi-schemas": "^2.0.4", - "@apidevtools/swagger-methods": "^3.0.2", - "@jsdevtools/ono": "^7.1.3", - "call-me-maybe": "^1.0.1", - "z-schema": "^5.0.1" - }, - "peerDependencies": { - "openapi-types": ">=7" - } - }, - "node_modules/@aws-crypto/sha256-browser": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", - "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-js": "^5.2.0", - "@aws-crypto/supports-web-crypto": "^5.2.0", - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha256-js": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", - "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/supports-web-crypto": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", - "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/util": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", - "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.222.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-sdk/client-ses": { - "version": "3.947.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-ses/-/client-ses-3.947.0.tgz", - "integrity": "sha512-Y9xaLPvQE7CW/8liyHdLOs6gxLHciBZhvuuZ/mDZLHtBmMSYm7wb/ikEfX7yid6nBITM/eAFURImRSKlQbnzlg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.947.0", - "@aws-sdk/credential-provider-node": "3.947.0", - "@aws-sdk/middleware-host-header": "3.936.0", - "@aws-sdk/middleware-logger": "3.936.0", - "@aws-sdk/middleware-recursion-detection": "3.936.0", - "@aws-sdk/middleware-user-agent": "3.947.0", - "@aws-sdk/region-config-resolver": "3.936.0", - "@aws-sdk/types": "3.936.0", - "@aws-sdk/util-endpoints": "3.936.0", - "@aws-sdk/util-user-agent-browser": "3.936.0", - "@aws-sdk/util-user-agent-node": "3.947.0", - "@smithy/config-resolver": "^4.4.3", - "@smithy/core": "^3.18.7", - "@smithy/fetch-http-handler": "^5.3.6", - "@smithy/hash-node": "^4.2.5", - "@smithy/invalid-dependency": "^4.2.5", - "@smithy/middleware-content-length": "^4.2.5", - "@smithy/middleware-endpoint": "^4.3.14", - "@smithy/middleware-retry": "^4.4.14", - "@smithy/middleware-serde": "^4.2.6", - "@smithy/middleware-stack": "^4.2.5", - "@smithy/node-config-provider": "^4.3.5", - "@smithy/node-http-handler": "^4.4.5", - "@smithy/protocol-http": "^5.3.5", - "@smithy/smithy-client": "^4.9.10", - "@smithy/types": "^4.9.0", - "@smithy/url-parser": "^4.2.5", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-body-length-node": "^4.2.1", - "@smithy/util-defaults-mode-browser": "^4.3.13", - "@smithy/util-defaults-mode-node": "^4.2.16", - "@smithy/util-endpoints": "^3.2.5", - "@smithy/util-middleware": "^4.2.5", - "@smithy/util-retry": "^4.2.5", - "@smithy/util-utf8": "^4.2.0", - "@smithy/util-waiter": "^4.2.5", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-sso": { - "version": "3.947.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.947.0.tgz", - "integrity": "sha512-sDwcO8SP290WSErY1S8pz8hTafeghKmmWjNVks86jDK30wx62CfazOTeU70IpWgrUBEygyXk/zPogHsUMbW2Rg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.947.0", - "@aws-sdk/middleware-host-header": "3.936.0", - "@aws-sdk/middleware-logger": "3.936.0", - "@aws-sdk/middleware-recursion-detection": "3.936.0", - "@aws-sdk/middleware-user-agent": "3.947.0", - "@aws-sdk/region-config-resolver": "3.936.0", - "@aws-sdk/types": "3.936.0", - "@aws-sdk/util-endpoints": "3.936.0", - "@aws-sdk/util-user-agent-browser": "3.936.0", - "@aws-sdk/util-user-agent-node": "3.947.0", - "@smithy/config-resolver": "^4.4.3", - "@smithy/core": "^3.18.7", - "@smithy/fetch-http-handler": "^5.3.6", - "@smithy/hash-node": "^4.2.5", - "@smithy/invalid-dependency": "^4.2.5", - "@smithy/middleware-content-length": "^4.2.5", - "@smithy/middleware-endpoint": "^4.3.14", - "@smithy/middleware-retry": "^4.4.14", - "@smithy/middleware-serde": "^4.2.6", - "@smithy/middleware-stack": "^4.2.5", - "@smithy/node-config-provider": "^4.3.5", - "@smithy/node-http-handler": "^4.4.5", - "@smithy/protocol-http": "^5.3.5", - "@smithy/smithy-client": "^4.9.10", - "@smithy/types": "^4.9.0", - "@smithy/url-parser": "^4.2.5", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-body-length-node": "^4.2.1", - "@smithy/util-defaults-mode-browser": "^4.3.13", - "@smithy/util-defaults-mode-node": "^4.2.16", - "@smithy/util-endpoints": "^3.2.5", - "@smithy/util-middleware": "^4.2.5", - "@smithy/util-retry": "^4.2.5", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/core": { - "version": "3.947.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.947.0.tgz", - "integrity": "sha512-Khq4zHhuAkvCFuFbgcy3GrZTzfSX7ZIjIcW1zRDxXRLZKRtuhnZdonqTUfaWi5K42/4OmxkYNpsO7X7trQOeHw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.936.0", - "@aws-sdk/xml-builder": "3.930.0", - "@smithy/core": "^3.18.7", - "@smithy/node-config-provider": "^4.3.5", - "@smithy/property-provider": "^4.2.5", - "@smithy/protocol-http": "^5.3.5", - "@smithy/signature-v4": "^5.3.5", - "@smithy/smithy-client": "^4.9.10", - "@smithy/types": "^4.9.0", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-middleware": "^4.2.5", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.947.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.947.0.tgz", - "integrity": "sha512-VR2V6dRELmzwAsCpK4GqxUi6UW5WNhAXS9F9AzWi5jvijwJo3nH92YNJUP4quMpgFZxJHEWyXLWgPjh9u0zYOA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.947.0", - "@aws-sdk/types": "3.936.0", - "@smithy/property-provider": "^4.2.5", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.947.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.947.0.tgz", - "integrity": "sha512-inF09lh9SlHj63Vmr5d+LmwPXZc2IbK8lAruhOr3KLsZAIHEgHgGPXWDC2ukTEMzg0pkexQ6FOhXXad6klK4RA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.947.0", - "@aws-sdk/types": "3.936.0", - "@smithy/fetch-http-handler": "^5.3.6", - "@smithy/node-http-handler": "^4.4.5", - "@smithy/property-provider": "^4.2.5", - "@smithy/protocol-http": "^5.3.5", - "@smithy/smithy-client": "^4.9.10", - "@smithy/types": "^4.9.0", - "@smithy/util-stream": "^4.5.6", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.947.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.947.0.tgz", - "integrity": "sha512-A2ZUgJUJZERjSzvCi2NR/hBVbVkTXPD0SdKcR/aITb30XwF+n3T963b+pJl90qhOspoy7h0IVYNR7u5Nr9tJdQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.947.0", - "@aws-sdk/credential-provider-env": "3.947.0", - "@aws-sdk/credential-provider-http": "3.947.0", - "@aws-sdk/credential-provider-login": "3.947.0", - "@aws-sdk/credential-provider-process": "3.947.0", - "@aws-sdk/credential-provider-sso": "3.947.0", - "@aws-sdk/credential-provider-web-identity": "3.947.0", - "@aws-sdk/nested-clients": "3.947.0", - "@aws-sdk/types": "3.936.0", - "@smithy/credential-provider-imds": "^4.2.5", - "@smithy/property-provider": "^4.2.5", - "@smithy/shared-ini-file-loader": "^4.4.0", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.947.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.947.0.tgz", - "integrity": "sha512-u7M3hazcB7aJiVwosNdJRbIJDzbwQ861NTtl6S0HmvWpixaVb7iyhJZWg8/plyUznboZGBm7JVEdxtxv3u0bTA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.947.0", - "@aws-sdk/nested-clients": "3.947.0", - "@aws-sdk/types": "3.936.0", - "@smithy/property-provider": "^4.2.5", - "@smithy/protocol-http": "^5.3.5", - "@smithy/shared-ini-file-loader": "^4.4.0", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.947.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.947.0.tgz", - "integrity": "sha512-S0Zqebr71KyrT6J4uYPhwV65g4V5uDPHnd7dt2W34FcyPu+hVC7Hx4MFmsPyVLeT5cMCkkZvmY3kAoEzgUPJJg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/credential-provider-env": "3.947.0", - "@aws-sdk/credential-provider-http": "3.947.0", - "@aws-sdk/credential-provider-ini": "3.947.0", - "@aws-sdk/credential-provider-process": "3.947.0", - "@aws-sdk/credential-provider-sso": "3.947.0", - "@aws-sdk/credential-provider-web-identity": "3.947.0", - "@aws-sdk/types": "3.936.0", - "@smithy/credential-provider-imds": "^4.2.5", - "@smithy/property-provider": "^4.2.5", - "@smithy/shared-ini-file-loader": "^4.4.0", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.947.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.947.0.tgz", - "integrity": "sha512-WpanFbHe08SP1hAJNeDdBDVz9SGgMu/gc0XJ9u3uNpW99nKZjDpvPRAdW7WLA4K6essMjxWkguIGNOpij6Do2Q==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.947.0", - "@aws-sdk/types": "3.936.0", - "@smithy/property-provider": "^4.2.5", - "@smithy/shared-ini-file-loader": "^4.4.0", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.947.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.947.0.tgz", - "integrity": "sha512-NktnVHTGaUMaozxycYrepvb3yfFquHTQ53lt6hBEVjYBzK3C4tVz0siUpr+5RMGLSiZ5bLBp2UjJPgwx4i4waQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/client-sso": "3.947.0", - "@aws-sdk/core": "3.947.0", - "@aws-sdk/token-providers": "3.947.0", - "@aws-sdk/types": "3.936.0", - "@smithy/property-provider": "^4.2.5", - "@smithy/shared-ini-file-loader": "^4.4.0", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.947.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.947.0.tgz", - "integrity": "sha512-gokm/e/YHiHLrZgLq4j8tNAn8RJDPbIcglFRKgy08q8DmAqHQ8MXAKW3eS0QjAuRXU9mcMmUo1NrX6FRNBCCPw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.947.0", - "@aws-sdk/nested-clients": "3.947.0", - "@aws-sdk/types": "3.936.0", - "@smithy/property-provider": "^4.2.5", - "@smithy/shared-ini-file-loader": "^4.4.0", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-host-header": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.936.0.tgz", - "integrity": "sha512-tAaObaAnsP1XnLGndfkGWFuzrJYuk9W0b/nLvol66t8FZExIAf/WdkT2NNAWOYxljVs++oHnyHBCxIlaHrzSiw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.936.0", - "@smithy/protocol-http": "^5.3.5", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-logger": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.936.0.tgz", - "integrity": "sha512-aPSJ12d3a3Ea5nyEnLbijCaaYJT2QjQ9iW+zGh5QcZYXmOGWbKVyPSxmVOboZQG+c1M8t6d2O7tqrwzIq8L8qw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.936.0", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.936.0.tgz", - "integrity": "sha512-l4aGbHpXM45YNgXggIux1HgsCVAvvBoqHPkqLnqMl9QVapfuSTjJHfDYDsx1Xxct6/m7qSMUzanBALhiaGO2fA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.936.0", - "@aws/lambda-invoke-store": "^0.2.0", - "@smithy/protocol-http": "^5.3.5", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.947.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.947.0.tgz", - "integrity": "sha512-7rpKV8YNgCP2R4F9RjWZFcD2R+SO/0R4VHIbY9iZJdH2MzzJ8ZG7h8dZ2m8QkQd1fjx4wrFJGGPJUTYXPV3baA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.947.0", - "@aws-sdk/types": "3.936.0", - "@aws-sdk/util-endpoints": "3.936.0", - "@smithy/core": "^3.18.7", - "@smithy/protocol-http": "^5.3.5", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/nested-clients": { - "version": "3.947.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.947.0.tgz", - "integrity": "sha512-DjRJEYNnHUTu9kGPPQDTSXquwSEd6myKR4ssI4FaYLFhdT3ldWpj73yYt807H3tdmhS7vPmdVqchSJnjurUQAw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.947.0", - "@aws-sdk/middleware-host-header": "3.936.0", - "@aws-sdk/middleware-logger": "3.936.0", - "@aws-sdk/middleware-recursion-detection": "3.936.0", - "@aws-sdk/middleware-user-agent": "3.947.0", - "@aws-sdk/region-config-resolver": "3.936.0", - "@aws-sdk/types": "3.936.0", - "@aws-sdk/util-endpoints": "3.936.0", - "@aws-sdk/util-user-agent-browser": "3.936.0", - "@aws-sdk/util-user-agent-node": "3.947.0", - "@smithy/config-resolver": "^4.4.3", - "@smithy/core": "^3.18.7", - "@smithy/fetch-http-handler": "^5.3.6", - "@smithy/hash-node": "^4.2.5", - "@smithy/invalid-dependency": "^4.2.5", - "@smithy/middleware-content-length": "^4.2.5", - "@smithy/middleware-endpoint": "^4.3.14", - "@smithy/middleware-retry": "^4.4.14", - "@smithy/middleware-serde": "^4.2.6", - "@smithy/middleware-stack": "^4.2.5", - "@smithy/node-config-provider": "^4.3.5", - "@smithy/node-http-handler": "^4.4.5", - "@smithy/protocol-http": "^5.3.5", - "@smithy/smithy-client": "^4.9.10", - "@smithy/types": "^4.9.0", - "@smithy/url-parser": "^4.2.5", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-body-length-node": "^4.2.1", - "@smithy/util-defaults-mode-browser": "^4.3.13", - "@smithy/util-defaults-mode-node": "^4.2.16", - "@smithy/util-endpoints": "^3.2.5", - "@smithy/util-middleware": "^4.2.5", - "@smithy/util-retry": "^4.2.5", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/region-config-resolver": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.936.0.tgz", - "integrity": "sha512-wOKhzzWsshXGduxO4pqSiNyL9oUtk4BEvjWm9aaq6Hmfdoydq6v6t0rAGHWPjFwy9z2haovGRi3C8IxdMB4muw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.936.0", - "@smithy/config-resolver": "^4.4.3", - "@smithy/node-config-provider": "^4.3.5", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/token-providers": { - "version": "3.947.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.947.0.tgz", - "integrity": "sha512-X/DyB8GuK44rsE89Tn5+s542B3PhGbXQSgV8lvqHDzvicwCt0tWny6790st6CPETrVVV2K3oJMfG5U3/jAmaZA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.947.0", - "@aws-sdk/nested-clients": "3.947.0", - "@aws-sdk/types": "3.936.0", - "@smithy/property-provider": "^4.2.5", - "@smithy/shared-ini-file-loader": "^4.4.0", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/types": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.936.0.tgz", - "integrity": "sha512-uz0/VlMd2pP5MepdrHizd+T+OKfyK4r3OA9JI+L/lPKg0YFQosdJNCKisr6o70E3dh8iMpFYxF1UN/4uZsyARg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/util-endpoints": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.936.0.tgz", - "integrity": "sha512-0Zx3Ntdpu+z9Wlm7JKUBOzS9EunwKAb4KdGUQQxDqh5Lc3ta5uBoub+FgmVuzwnmBu9U1Os8UuwVTH0Lgu+P5w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.936.0", - "@smithy/types": "^4.9.0", - "@smithy/url-parser": "^4.2.5", - "@smithy/util-endpoints": "^3.2.5", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/util-locate-window": { - "version": "3.893.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.893.0.tgz", - "integrity": "sha512-T89pFfgat6c8nMmpI8eKjBcDcgJq36+m9oiXbcUzeU55MP9ZuGgBomGjGnHaEyF36jenW9gmg3NfZDm0AO2XPg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.936.0.tgz", - "integrity": "sha512-eZ/XF6NxMtu+iCma58GRNRxSq4lHo6zHQLOZRIeL/ghqYJirqHdenMOwrzPettj60KWlv827RVebP9oNVrwZbw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.936.0", - "@smithy/types": "^4.9.0", - "bowser": "^2.11.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.947.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.947.0.tgz", - "integrity": "sha512-+vhHoDrdbb+zerV4noQk1DHaUMNzWFWPpPYjVTwW2186k5BEJIecAMChYkghRrBVJ3KPWP1+JnZwOd72F3d4rQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/middleware-user-agent": "3.947.0", - "@aws-sdk/types": "3.936.0", - "@smithy/node-config-provider": "^4.3.5", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "aws-crt": ">=1.0.0" - }, - "peerDependenciesMeta": { - "aws-crt": { - "optional": true - } - } - }, - "node_modules/@aws-sdk/xml-builder": { - "version": "3.930.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.930.0.tgz", - "integrity": "sha512-YIfkD17GocxdmlUVc3ia52QhcWuRIUJonbF8A2CYfcWNV3HzvAqpcPeC0bYUhkK+8e8YO1ARnLKZQE0TlwzorA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.9.0", - "fast-xml-parser": "5.2.5", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws/lambda-invoke-store": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.2.tgz", - "integrity": "sha512-C0NBLsIqzDIae8HFw9YIrIBsbc0xTiOtt7fAukGPnqQ/+zZNaq+4jhuccltK0QuWHBnNm/a6kLIRA6GFiM10eg==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", - "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", - "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5", - "@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.28.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", - "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", - "@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.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.27.2", - "@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.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", - "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.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.5" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-syntax-async-generators": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", - "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-bigint": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", - "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-class-properties": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", - "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.12.13" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-class-static-block": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", - "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", - "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", - "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-syntax-import-meta": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", - "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-json-strings": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", - "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", - "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", - "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-syntax-logical-assignment-operators": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", - "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", - "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-numeric-separator": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", - "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-object-rest-spread": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", - "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-catch-binding": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", - "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-chaining": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", - "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-private-property-in-object": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", - "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-top-level-await": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", - "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", - "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", - "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/runtime": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", - "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", - "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", - "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/@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@colors/colors": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", - "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", - "license": "MIT", - "engines": { - "node": ">=0.1.90" - } - }, - "node_modules/@dabh/diagnostics": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", - "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", - "license": "MIT", - "dependencies": { - "@so-ric/colorspace": "^1.1.6", - "enabled": "2.0.x", - "kuler": "^2.0.0" - } - }, - "node_modules/@emnapi/core": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", - "integrity": "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.1.0", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", - "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.1.tgz", - "integrity": "sha512-HHB50pdsBX6k47S4u5g/CaLjqS3qwaOVE5ILsq64jyzgMhLuCuZ8rGzM9yhsAjfjkbgUPMzZEPa7DAp7yz6vuA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.1.tgz", - "integrity": "sha512-kFqa6/UcaTbGm/NncN9kzVOODjhZW8e+FRdSeypWe6j33gzclHtwlANs26JrupOntlcWmB0u8+8HZo8s7thHvg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.1.tgz", - "integrity": "sha512-45fuKmAJpxnQWixOGCrS+ro4Uvb4Re9+UTieUY2f8AEc+t7d4AaZ6eUJ3Hva7dtrxAAWHtlEFsXFMAgNnGU9uQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.1.tgz", - "integrity": "sha512-LBEpOz0BsgMEeHgenf5aqmn/lLNTFXVfoWMUox8CtWWYK9X4jmQzWjoGoNb8lmAYml/tQ/Ysvm8q7szu7BoxRQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.1.tgz", - "integrity": "sha512-veg7fL8eMSCVKL7IW4pxb54QERtedFDfY/ASrumK/SbFsXnRazxY4YykN/THYqFnFwJ0aVjiUrVG2PwcdAEqQQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.1.tgz", - "integrity": "sha512-+3ELd+nTzhfWb07Vol7EZ+5PTbJ/u74nC6iv4/lwIU99Ip5uuY6QoIf0Hn4m2HoV0qcnRivN3KSqc+FyCHjoVQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.1.tgz", - "integrity": "sha512-/8Rfgns4XD9XOSXlzUDepG8PX+AVWHliYlUkFI3K3GB6tqbdjYqdhcb4BKRd7C0BhZSoaCxhv8kTcBrcZWP+xg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.1.tgz", - "integrity": "sha512-GITpD8dK9C+r+5yRT/UKVT36h/DQLOHdwGVwwoHidlnA168oD3uxA878XloXebK4Ul3gDBBIvEdL7go9gCUFzQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.1.tgz", - "integrity": "sha512-ieMID0JRZY/ZeCrsFQ3Y3NlHNCqIhTprJfDgSB3/lv5jJZ8FX3hqPyXWhe+gvS5ARMBJ242PM+VNz/ctNj//eA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.1.tgz", - "integrity": "sha512-W9//kCrh/6in9rWIBdKaMtuTTzNj6jSeG/haWBADqLLa9P8O5YSRDzgD5y9QBok4AYlzS6ARHifAb75V6G670Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.1.tgz", - "integrity": "sha512-VIUV4z8GD8rtSVMfAj1aXFahsi/+tcoXXNYmXgzISL+KB381vbSTNdeZHHHIYqFyXcoEhu9n5cT+05tRv13rlw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.1.tgz", - "integrity": "sha512-l4rfiiJRN7sTNI//ff65zJ9z8U+k6zcCg0LALU5iEWzY+a1mVZ8iWC1k5EsNKThZ7XCQ6YWtsZ8EWYm7r1UEsg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.1.tgz", - "integrity": "sha512-U0bEuAOLvO/DWFdygTHWY8C067FXz+UbzKgxYhXC0fDieFa0kDIra1FAhsAARRJbvEyso8aAqvPdNxzWuStBnA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.1.tgz", - "integrity": "sha512-NzdQ/Xwu6vPSf/GkdmRNsOfIeSGnh7muundsWItmBsVpMoNPVpM61qNzAVY3pZ1glzzAxLR40UyYM23eaDDbYQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.1.tgz", - "integrity": "sha512-7zlw8p3IApcsN7mFw0O1Z1PyEk6PlKMu18roImfl3iQHTnr/yAfYv6s4hXPidbDoI2Q0pW+5xeoM4eTCC0UdrQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.1.tgz", - "integrity": "sha512-cGj5wli+G+nkVQdZo3+7FDKC25Uh4ZVwOAK6A06Hsvgr8WqBBuOy/1s+PUEd/6Je+vjfm6stX0kmib5b/O2Ykw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.1.tgz", - "integrity": "sha512-z3H/HYI9MM0HTv3hQZ81f+AKb+yEoCRlUby1F80vbQ5XdzEMyY/9iNlAmhqiBKw4MJXwfgsh7ERGEOhrM1niMA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.1.tgz", - "integrity": "sha512-wzC24DxAvk8Em01YmVXyjl96Mr+ecTPyOuADAvjGg+fyBpGmxmcr2E5ttf7Im8D0sXZihpxzO1isus8MdjMCXQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.1.tgz", - "integrity": "sha512-1YQ8ybGi2yIXswu6eNzJsrYIGFpnlzEWRl6iR5gMgmsrR0FcNoV1m9k9sc3PuP5rUBLshOZylc9nqSgymI+TYg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.1.tgz", - "integrity": "sha512-5Z+DzLCrq5wmU7RDaMDe2DVXMRm2tTDvX2KU14JJVBN2CT/qov7XVix85QoJqHltpvAOZUAc3ndU56HSMWrv8g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.1.tgz", - "integrity": "sha512-Q73ENzIdPF5jap4wqLtsfh8YbYSZ8Q0wnxplOlZUOyZy7B4ZKW8DXGWgTCZmF8VWD7Tciwv5F4NsRf6vYlZtqg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.1.tgz", - "integrity": "sha512-ajbHrGM/XiK+sXM0JzEbJAen+0E+JMQZ2l4RR4VFwvV9JEERx+oxtgkpoKv1SevhjavK2z2ReHk32pjzktWbGg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.1.tgz", - "integrity": "sha512-IPUW+y4VIjuDVn+OMzHc5FV4GubIwPnsz6ubkvN8cuhEqH81NovB53IUlrlBkPMEPxvNnf79MGBoz8rZ2iW8HA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.1.tgz", - "integrity": "sha512-RIVRWiljWA6CdVu8zkWcRmGP7iRRIIwvhDKem8UMBjPql2TXM5PkDVvvrzMtj1V+WFPB4K7zkIGM7VzRtFkjdg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.1.tgz", - "integrity": "sha512-2BR5M8CPbptC1AK5JbJT1fWrHLvejwZidKx3UMSF0ecHMa+smhi16drIrCEggkgviBwLYd5nwrFLSl5Kho96RQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.1.tgz", - "integrity": "sha512-d5X6RMYv6taIymSk8JBP+nxv8DQAMY6A51GPgusqLdK9wBz5wWIXy1KjTck6HnjE9hqJzJRdk+1p/t5soSbCtw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", - "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.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", - "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@eslint/js": { - "version": "9.39.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", - "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.4.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/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "camelcase": "^5.3.1", - "find-up": "^4.1.0", - "get-package-type": "^0.1.0", - "js-yaml": "^3.13.1", - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/console": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.2.0.tgz", - "integrity": "sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.2.0", - "@types/node": "*", - "chalk": "^4.1.2", - "jest-message-util": "30.2.0", - "jest-util": "30.2.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/core": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.2.0.tgz", - "integrity": "sha512-03W6IhuhjqTlpzh/ojut/pDB2LPRygyWX8ExpgHtQA8H/3K7+1vKmcINx5UzeOX1se6YEsBsOHQ1CRzf3fOwTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/console": "30.2.0", - "@jest/pattern": "30.0.1", - "@jest/reporters": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", - "@types/node": "*", - "ansi-escapes": "^4.3.2", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "exit-x": "^0.2.2", - "graceful-fs": "^4.2.11", - "jest-changed-files": "30.2.0", - "jest-config": "30.2.0", - "jest-haste-map": "30.2.0", - "jest-message-util": "30.2.0", - "jest-regex-util": "30.0.1", - "jest-resolve": "30.2.0", - "jest-resolve-dependencies": "30.2.0", - "jest-runner": "30.2.0", - "jest-runtime": "30.2.0", - "jest-snapshot": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", - "jest-watcher": "30.2.0", - "micromatch": "^4.0.8", - "pretty-format": "30.2.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/@jest/diff-sequences": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", - "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/environment": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.2.0.tgz", - "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/fake-timers": "30.2.0", - "@jest/types": "30.2.0", - "@types/node": "*", - "jest-mock": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/expect": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.2.0.tgz", - "integrity": "sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "expect": "30.2.0", - "jest-snapshot": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/expect-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.2.0.tgz", - "integrity": "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/get-type": "30.1.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/fake-timers": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.2.0.tgz", - "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.2.0", - "@sinonjs/fake-timers": "^13.0.0", - "@types/node": "*", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-util": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/get-type": { - "version": "30.1.0", - "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", - "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/globals": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.2.0.tgz", - "integrity": "sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "30.2.0", - "@jest/expect": "30.2.0", - "@jest/types": "30.2.0", - "jest-mock": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/pattern": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", - "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "jest-regex-util": "30.0.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/reporters": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.2.0.tgz", - "integrity": "sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", - "@jridgewell/trace-mapping": "^0.3.25", - "@types/node": "*", - "chalk": "^4.1.2", - "collect-v8-coverage": "^1.0.2", - "exit-x": "^0.2.2", - "glob": "^10.3.10", - "graceful-fs": "^4.2.11", - "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-instrument": "^6.0.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^5.0.0", - "istanbul-reports": "^3.1.3", - "jest-message-util": "30.2.0", - "jest-util": "30.2.0", - "jest-worker": "30.2.0", - "slash": "^3.0.0", - "string-length": "^4.0.2", - "v8-to-istanbul": "^9.0.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/@jest/schemas": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", - "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.34.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/snapshot-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.2.0.tgz", - "integrity": "sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.2.0", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "natural-compare": "^1.4.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/source-map": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", - "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", - "callsites": "^3.1.0", - "graceful-fs": "^4.2.11" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/test-result": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.2.0.tgz", - "integrity": "sha512-RF+Z+0CCHkARz5HT9mcQCBulb1wgCP3FBvl9VFokMX27acKphwyQsNuWH3c+ojd1LeWBLoTYoxF0zm6S/66mjg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/console": "30.2.0", - "@jest/types": "30.2.0", - "@types/istanbul-lib-coverage": "^2.0.6", - "collect-v8-coverage": "^1.0.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/test-sequencer": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.2.0.tgz", - "integrity": "sha512-wXKgU/lk8fKXMu/l5Hog1R61bL4q5GCdT6OJvdAFz1P+QrpoFuLU68eoKuVc4RbrTtNnTL5FByhWdLgOPSph+Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/test-result": "30.2.0", - "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/transform": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.2.0.tgz", - "integrity": "sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.27.4", - "@jest/types": "30.2.0", - "@jridgewell/trace-mapping": "^0.3.25", - "babel-plugin-istanbul": "^7.0.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": "30.2.0", - "jest-regex-util": "30.0.1", - "jest-util": "30.2.0", - "micromatch": "^4.0.8", - "pirates": "^4.0.7", - "slash": "^3.0.0", - "write-file-atomic": "^5.0.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/types": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", - "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/pattern": "30.0.1", - "@jest/schemas": "30.0.5", - "@types/istanbul-lib-coverage": "^2.0.6", - "@types/istanbul-reports": "^3.0.4", - "@types/node": "*", - "@types/yargs": "^17.0.33", - "chalk": "^4.1.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "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/@jsdevtools/ono": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", - "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", - "license": "MIT" - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", - "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.10.0" - } - }, - "node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@paralleldrive/cuid2": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", - "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@noble/hashes": "^1.1.5" - } - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@pkgr/core": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", - "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/pkgr" - } - }, - "node_modules/@scarf/scarf": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", - "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", - "hasInstallScript": true, - "license": "Apache-2.0" - }, - "node_modules/@sinclair/typebox": { - "version": "0.34.41", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", - "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sinonjs/commons": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", - "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "type-detect": "4.0.8" - } - }, - "node_modules/@sinonjs/fake-timers": { - "version": "13.0.5", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", - "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.1" - } - }, - "node_modules/@smithy/abort-controller": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.5.tgz", - "integrity": "sha512-j7HwVkBw68YW8UmFRcjZOmssE77Rvk0GWAIN1oFBhsaovQmZWYCIcGa9/pwRB0ExI8Sk9MWNALTjftjHZea7VA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/config-resolver": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.3.tgz", - "integrity": "sha512-ezHLe1tKLUxDJo2LHtDuEDyWXolw8WGOR92qb4bQdWq/zKenO5BvctZGrVJBK08zjezSk7bmbKFOXIVyChvDLw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.5", - "@smithy/types": "^4.9.0", - "@smithy/util-config-provider": "^4.2.0", - "@smithy/util-endpoints": "^3.2.5", - "@smithy/util-middleware": "^4.2.5", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/core": { - "version": "3.18.7", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.18.7.tgz", - "integrity": "sha512-axG9MvKhMWOhFbvf5y2DuyTxQueO0dkedY9QC3mAfndLosRI/9LJv8WaL0mw7ubNhsO4IuXX9/9dYGPFvHrqlw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/middleware-serde": "^4.2.6", - "@smithy/protocol-http": "^5.3.5", - "@smithy/types": "^4.9.0", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-middleware": "^4.2.5", - "@smithy/util-stream": "^4.5.6", - "@smithy/util-utf8": "^4.2.0", - "@smithy/uuid": "^1.1.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/credential-provider-imds": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.5.tgz", - "integrity": "sha512-BZwotjoZWn9+36nimwm/OLIcVe+KYRwzMjfhd4QT7QxPm9WY0HiOV8t/Wlh+HVUif0SBVV7ksq8//hPaBC/okQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.5", - "@smithy/property-provider": "^4.2.5", - "@smithy/types": "^4.9.0", - "@smithy/url-parser": "^4.2.5", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/fetch-http-handler": { - "version": "5.3.6", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.6.tgz", - "integrity": "sha512-3+RG3EA6BBJ/ofZUeTFJA7mHfSYrZtQIrDP9dI8Lf7X6Jbos2jptuLrAAteDiFVrmbEmLSuRG/bUKzfAXk7dhg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/protocol-http": "^5.3.5", - "@smithy/querystring-builder": "^4.2.5", - "@smithy/types": "^4.9.0", - "@smithy/util-base64": "^4.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/hash-node": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.5.tgz", - "integrity": "sha512-DpYX914YOfA3UDT9CN1BM787PcHfWRBB43fFGCYrZFUH0Jv+5t8yYl+Pd5PW4+QzoGEDvn5d5QIO4j2HyYZQSA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.9.0", - "@smithy/util-buffer-from": "^4.2.0", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/invalid-dependency": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.5.tgz", - "integrity": "sha512-2L2erASEro1WC5nV+plwIMxrTXpvpfzl4e+Nre6vBVRR2HKeGGcvpJyyL3/PpiSg+cJG2KpTmZmq934Olb6e5A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/is-array-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.0.tgz", - "integrity": "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-content-length": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.5.tgz", - "integrity": "sha512-Y/RabVa5vbl5FuHYV2vUCwvh/dqzrEY/K2yWPSqvhFUwIY0atLqO4TienjBXakoy4zrKAMCZwg+YEqmH7jaN7A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/protocol-http": "^5.3.5", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-endpoint": { - "version": "4.3.14", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.3.14.tgz", - "integrity": "sha512-v0q4uTKgBM8dsqGjqsabZQyH85nFaTnFcgpWU1uydKFsdyyMzfvOkNum9G7VK+dOP01vUnoZxIeRiJ6uD0kjIg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.18.7", - "@smithy/middleware-serde": "^4.2.6", - "@smithy/node-config-provider": "^4.3.5", - "@smithy/shared-ini-file-loader": "^4.4.0", - "@smithy/types": "^4.9.0", - "@smithy/url-parser": "^4.2.5", - "@smithy/util-middleware": "^4.2.5", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-retry": { - "version": "4.4.14", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.14.tgz", - "integrity": "sha512-Z2DG8Ej7FyWG1UA+7HceINtSLzswUgs2np3sZX0YBBxCt+CXG4QUxv88ZDS3+2/1ldW7LqtSY1UO/6VQ1pND8Q==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.5", - "@smithy/protocol-http": "^5.3.5", - "@smithy/service-error-classification": "^4.2.5", - "@smithy/smithy-client": "^4.9.10", - "@smithy/types": "^4.9.0", - "@smithy/util-middleware": "^4.2.5", - "@smithy/util-retry": "^4.2.5", - "@smithy/uuid": "^1.1.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-serde": { - "version": "4.2.6", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.6.tgz", - "integrity": "sha512-VkLoE/z7e2g8pirwisLz8XJWedUSY8my/qrp81VmAdyrhi94T+riBfwP+AOEEFR9rFTSonC/5D2eWNmFabHyGQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/protocol-http": "^5.3.5", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-stack": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.5.tgz", - "integrity": "sha512-bYrutc+neOyWxtZdbB2USbQttZN0mXaOyYLIsaTbJhFsfpXyGWUxJpEuO1rJ8IIJm2qH4+xJT0mxUSsEDTYwdQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/node-config-provider": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.5.tgz", - "integrity": "sha512-UTurh1C4qkVCtqggI36DGbLB2Kv8UlcFdMXDcWMbqVY2uRg0XmT9Pb4Vj6oSQ34eizO1fvR0RnFV4Axw4IrrAg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/property-provider": "^4.2.5", - "@smithy/shared-ini-file-loader": "^4.4.0", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/node-http-handler": { - "version": "4.4.5", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.5.tgz", - "integrity": "sha512-CMnzM9R2WqlqXQGtIlsHMEZfXKJVTIrqCNoSd/QpAyp+Dw0a1Vps13l6ma1fH8g7zSPNsA59B/kWgeylFuA/lw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/abort-controller": "^4.2.5", - "@smithy/protocol-http": "^5.3.5", - "@smithy/querystring-builder": "^4.2.5", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/property-provider": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.5.tgz", - "integrity": "sha512-8iLN1XSE1rl4MuxvQ+5OSk/Zb5El7NJZ1td6Tn+8dQQHIjp59Lwl6bd0+nzw6SKm2wSSriH2v/I9LPzUic7EOg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/protocol-http": { - "version": "5.3.5", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.5.tgz", - "integrity": "sha512-RlaL+sA0LNMp03bf7XPbFmT5gN+w3besXSWMkA8rcmxLSVfiEXElQi4O2IWwPfxzcHkxqrwBFMbngB8yx/RvaQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/querystring-builder": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.5.tgz", - "integrity": "sha512-y98otMI1saoajeik2kLfGyRp11e5U/iJYH/wLCh3aTV/XutbGT9nziKGkgCaMD1ghK7p6htHMm6b6scl9JRUWg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.9.0", - "@smithy/util-uri-escape": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/querystring-parser": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.5.tgz", - "integrity": "sha512-031WCTdPYgiQRYNPXznHXof2YM0GwL6SeaSyTH/P72M1Vz73TvCNH2Nq8Iu2IEPq9QP2yx0/nrw5YmSeAi/AjQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/service-error-classification": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.5.tgz", - "integrity": "sha512-8fEvK+WPE3wUAcDvqDQG1Vk3ANLR8Px979te96m84CbKAjBVf25rPYSzb4xU4hlTyho7VhOGnh5i62D/JVF0JQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.9.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.0.tgz", - "integrity": "sha512-5WmZ5+kJgJDjwXXIzr1vDTG+RhF9wzSODQBfkrQ2VVkYALKGvZX1lgVSxEkgicSAFnFhPj5rudJV0zoinqS0bA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/signature-v4": { - "version": "5.3.5", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.5.tgz", - "integrity": "sha512-xSUfMu1FT7ccfSXkoLl/QRQBi2rOvi3tiBZU2Tdy3I6cgvZ6SEi9QNey+lqps/sJRnogIS+lq+B1gxxbra2a/w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^4.2.0", - "@smithy/protocol-http": "^5.3.5", - "@smithy/types": "^4.9.0", - "@smithy/util-hex-encoding": "^4.2.0", - "@smithy/util-middleware": "^4.2.5", - "@smithy/util-uri-escape": "^4.2.0", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/smithy-client": { - "version": "4.9.10", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.9.10.tgz", - "integrity": "sha512-Jaoz4Jw1QYHc1EFww/E6gVtNjhoDU+gwRKqXP6C3LKYqqH2UQhP8tMP3+t/ePrhaze7fhLE8vS2q6vVxBANFTQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.18.7", - "@smithy/middleware-endpoint": "^4.3.14", - "@smithy/middleware-stack": "^4.2.5", - "@smithy/protocol-http": "^5.3.5", - "@smithy/types": "^4.9.0", - "@smithy/util-stream": "^4.5.6", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/types": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.9.0.tgz", - "integrity": "sha512-MvUbdnXDTwykR8cB1WZvNNwqoWVaTRA0RLlLmf/cIFNMM2cKWz01X4Ly6SMC4Kks30r8tT3Cty0jmeWfiuyHTA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/url-parser": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.5.tgz", - "integrity": "sha512-VaxMGsilqFnK1CeBX+LXnSuaMx4sTL/6znSZh2829txWieazdVxr54HmiyTsIbpOTLcf5nYpq9lpzmwRdxj6rQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/querystring-parser": "^4.2.5", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-base64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.0.tgz", - "integrity": "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^4.2.0", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-body-length-browser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.0.tgz", - "integrity": "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-body-length-node": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.1.tgz", - "integrity": "sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-buffer-from": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.0.tgz", - "integrity": "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-config-provider": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.0.tgz", - "integrity": "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.3.13", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.13.tgz", - "integrity": "sha512-hlVLdAGrVfyNei+pKIgqDTxfu/ZI2NSyqj4IDxKd5bIsIqwR/dSlkxlPaYxFiIaDVrBy0he8orsFy+Cz119XvA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/property-provider": "^4.2.5", - "@smithy/smithy-client": "^4.9.10", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.2.16", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.16.tgz", - "integrity": "sha512-F1t22IUiJLHrxW9W1CQ6B9PN+skZ9cqSuzB18Eh06HrJPbjsyZ7ZHecAKw80DQtyGTRcVfeukKaCRYebFwclbg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/config-resolver": "^4.4.3", - "@smithy/credential-provider-imds": "^4.2.5", - "@smithy/node-config-provider": "^4.3.5", - "@smithy/property-provider": "^4.2.5", - "@smithy/smithy-client": "^4.9.10", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-endpoints": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.5.tgz", - "integrity": "sha512-3O63AAWu2cSNQZp+ayl9I3NapW1p1rR5mlVHcF6hAB1dPZUQFfRPYtplWX/3xrzWthPGj5FqB12taJJCfH6s8A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.5", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-hex-encoding": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz", - "integrity": "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-middleware": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.5.tgz", - "integrity": "sha512-6Y3+rvBF7+PZOc40ybeZMcGln6xJGVeY60E7jy9Mv5iKpMJpHgRE6dKy9ScsVxvfAYuEX4Q9a65DQX90KaQ3bA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-retry": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.5.tgz", - "integrity": "sha512-GBj3+EZBbN4NAqJ/7pAhsXdfzdlznOh8PydUijy6FpNIMnHPSMO2/rP4HKu+UFeikJxShERk528oy7GT79YiJg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/service-error-classification": "^4.2.5", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-stream": { - "version": "4.5.6", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.6.tgz", - "integrity": "sha512-qWw/UM59TiaFrPevefOZ8CNBKbYEP6wBAIlLqxn3VAIo9rgnTNc4ASbVrqDmhuwI87usnjhdQrxodzAGFFzbRQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/fetch-http-handler": "^5.3.6", - "@smithy/node-http-handler": "^4.4.5", - "@smithy/types": "^4.9.0", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-buffer-from": "^4.2.0", - "@smithy/util-hex-encoding": "^4.2.0", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-uri-escape": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.0.tgz", - "integrity": "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-utf8": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.0.tgz", - "integrity": "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-waiter": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.5.tgz", - "integrity": "sha512-Dbun99A3InifQdIrsXZ+QLcC0PGBPAdrl4cj1mTgJvyc9N2zf7QSxg8TBkzsCmGJdE3TLbO9ycwpY0EkWahQ/g==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/abort-controller": "^4.2.5", - "@smithy/types": "^4.9.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/uuid": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.0.tgz", - "integrity": "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@so-ric/colorspace": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", - "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==", - "license": "MIT", - "dependencies": { - "color": "^5.0.2", - "text-hex": "1.0.x" - } - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "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/bcryptjs": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", - "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", - "dev": true, - "license": "MIT" - }, - "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/compression": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz", - "integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/express": "*", - "@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/cookiejar": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", - "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/cors": { - "version": "2.8.19", - "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", - "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "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/express": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", - "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^5.0.0", - "@types/serve-static": "^2" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz", - "integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==", - "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/istanbul-lib-coverage": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", - "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/istanbul-lib-report": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", - "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/istanbul-lib-coverage": "*" - } - }, - "node_modules/@types/istanbul-reports": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", - "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/istanbul-lib-report": "*" - } - }, - "node_modules/@types/jest": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", - "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", - "dev": true, - "license": "MIT", - "dependencies": { - "expect": "^30.0.0", - "pretty-format": "^30.0.0" - } - }, - "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==", - "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/methods": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", - "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/morgan": { - "version": "1.9.10", - "resolved": "https://registry.npmjs.org/@types/morgan/-/morgan-1.9.10.tgz", - "integrity": "sha512-sS4A1zheMvsADRVfT0lYbJ4S9lmsey8Zo2F7cnbYjWHP67Q0AwMYuuzLlkIM2N8gAbb9cubhIVFwcIN2XyYCkA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "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": "20.19.26", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.26.tgz", - "integrity": "sha512-0l6cjgF0XnihUpndDhk+nyD3exio3iKaYROSgvh/qSevPXax3L8p5DBRFjbvalnwatGgHEQn2R88y2fA3g4irg==", - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/node-fetch": { - "version": "2.6.13", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", - "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "form-data": "^4.0.4" - } - }, - "node_modules/@types/nodemailer": { - "version": "6.4.21", - "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.21.tgz", - "integrity": "sha512-Eix+sb/Nj28MNnWvO2X1OLrk5vuD4C9SMnb2Vf4itWnxphYeSceqkFX7IdmxTzn+dvmnNz7paMbg4Uc60wSfJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@aws-sdk/client-ses": "^3.731.1", - "@types/node": "*" - } - }, - "node_modules/@types/oauth": { - "version": "0.9.6", - "resolved": "https://registry.npmjs.org/@types/oauth/-/oauth-0.9.6.tgz", - "integrity": "sha512-H9TRCVKBNOhZZmyHLqFt9drPM9l+ShWiqqJijU1B8P3DX3ub84NjxDuy+Hjrz+fEca5Kwip3qPMKNyiLgNJtIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/passport": { - "version": "1.0.17", - "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", - "integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/express": "*" - } - }, - "node_modules/@types/passport-facebook": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/passport-facebook/-/passport-facebook-3.0.4.tgz", - "integrity": "sha512-dZ7/758O0b7s2EyRUZJ24X93k8Nncm5UXLQPYg9bBJNE5ZwvD314QfDFYl0i4DlIPLcYGWkJ5Et0DXt6DAk71A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/express": "*", - "@types/passport": "*", - "@types/passport-oauth2": "*" - } - }, - "node_modules/@types/passport-github2": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@types/passport-github2/-/passport-github2-1.2.9.tgz", - "integrity": "sha512-/nMfiPK2E6GKttwBzwj0Wjaot8eHrM57hnWxu52o6becr5/kXlH/4yE2v2rh234WGvSgEEzIII02Nc5oC5xEHA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/express": "*", - "@types/passport": "*", - "@types/passport-oauth2": "*" - } - }, - "node_modules/@types/passport-google-oauth20": { - "version": "2.0.17", - "resolved": "https://registry.npmjs.org/@types/passport-google-oauth20/-/passport-google-oauth20-2.0.17.tgz", - "integrity": "sha512-MHNOd2l7gOTCn3iS+wInPQMiukliAUvMpODO3VlXxOiwNEMSyzV7UNvAdqxSN872o8OXx1SqPDVT6tLW74AtqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/express": "*", - "@types/passport": "*", - "@types/passport-oauth2": "*" - } - }, - "node_modules/@types/passport-local": { - "version": "1.0.38", - "resolved": "https://registry.npmjs.org/@types/passport-local/-/passport-local-1.0.38.tgz", - "integrity": "sha512-nsrW4A963lYE7lNTv9cr5WmiUD1ibYJvWrpE13oxApFsRt77b0RdtZvKbCdNIY4v/QZ6TRQWaDDEwV1kCTmcXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/express": "*", - "@types/passport": "*", - "@types/passport-strategy": "*" - } - }, - "node_modules/@types/passport-oauth2": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@types/passport-oauth2/-/passport-oauth2-1.8.0.tgz", - "integrity": "sha512-6//z+4orIOy/g3zx17HyQ71GSRK4bs7Sb+zFasRoc2xzlv7ZCJ+vkDBYFci8U6HY+or6Zy7ajf4mz4rK7nsWJQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/express": "*", - "@types/oauth": "*", - "@types/passport": "*" - } - }, - "node_modules/@types/passport-strategy": { - "version": "0.2.38", - "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", - "integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/express": "*", - "@types/passport": "*" - } - }, - "node_modules/@types/pg": { - "version": "8.15.6", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.6.tgz", - "integrity": "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "pg-protocol": "*", - "pg-types": "^2.2.0" - } - }, - "node_modules/@types/qrcode": { - "version": "1.5.6", - "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", - "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", - "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": "2.2.0", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/http-errors": "*", - "@types/node": "*" - } - }, - "node_modules/@types/speakeasy": { - "version": "2.0.10", - "resolved": "https://registry.npmjs.org/@types/speakeasy/-/speakeasy-2.0.10.tgz", - "integrity": "sha512-QVRlDW5r4yl7p7xkNIbAIC/JtyOcClDIIdKfuG7PWdDT1MmyhtXSANsildohy0K+Lmvf/9RUtLbNLMacvrVwxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/stack-utils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", - "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/superagent": { - "version": "8.1.9", - "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", - "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/cookiejar": "^2.1.5", - "@types/methods": "^1.1.4", - "@types/node": "*", - "form-data": "^4.0.0" - } - }, - "node_modules/@types/supertest": { - "version": "2.0.16", - "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-2.0.16.tgz", - "integrity": "sha512-6c2ogktZ06tr2ENoZivgm7YnprnhYE4ZoXGMY+oA7IuAf17M8FWvujXZGmxLv8y0PTyts4x5A+erSwVUFA8XSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/superagent": "*" - } - }, - "node_modules/@types/swagger-jsdoc": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/@types/swagger-jsdoc/-/swagger-jsdoc-6.0.4.tgz", - "integrity": "sha512-W+Xw5epcOZrF/AooUM/PccNMSAFOKWZA5dasNyMujTwsBkU74njSJBpvCCJhHAJ95XRMzQrrW844Btu0uoetwQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/swagger-ui-express": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.8.tgz", - "integrity": "sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/express": "*", - "@types/serve-static": "*" - } - }, - "node_modules/@types/triple-beam": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", - "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", - "license": "MIT" - }, - "node_modules/@types/uuid": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", - "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/yargs": { - "version": "17.0.35", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", - "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/yargs-parser": "*" - } - }, - "node_modules/@types/yargs-parser": { - "version": "21.0.3", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", - "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.49.0.tgz", - "integrity": "sha512-/wJN0/DKkmRUMXjZUXYZpD1NEQzQAAn9QWfGwo+Ai8gnzqH7tvqS7oNVdTjKqOcPyVIdZdyCMoqN66Ia789e7g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.49.0", - "@typescript-eslint/types": "^8.49.0", - "debug": "^4.3.4" - }, - "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.0.0" - } - }, - "node_modules/@typescript-eslint/project-service/node_modules/@typescript-eslint/types": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.49.0.tgz", - "integrity": "sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ==", - "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/tsconfig-utils": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.49.0.tgz", - "integrity": "sha512-8prixNi1/6nawsRYxet4YOhnbW+W9FK/bQPxsGB1D3ZrDzbJ5FXw5XmzxZv82X3B+ZccuSxo/X8q9nQ+mFecWA==", - "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.0.0" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.49.0.tgz", - "integrity": "sha512-KTExJfQ+svY8I10P4HdxKzWsvtVnsuCifU5MvXrRwoP2KOlNZ9ADNEWWsQTJgMxLzS5VLQKDjkCT/YzgsnqmZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.49.0", - "@typescript-eslint/typescript-estree": "8.49.0", - "@typescript-eslint/utils": "8.49.0", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.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", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.49.0.tgz", - "integrity": "sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ==", - "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/type-utils/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.49.0.tgz", - "integrity": "sha512-jrLdRuAbPfPIdYNppHJ/D0wN+wwNfJ32YTAm10eJVsFmrVpXQnDWBn8niCSMlWjvml8jsce5E/O+86IQtTbJWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.49.0", - "@typescript-eslint/tsconfig-utils": "8.49.0", - "@typescript-eslint/types": "8.49.0", - "@typescript-eslint/visitor-keys": "8.49.0", - "debug": "^4.3.4", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.1.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.0.0" - } - }, - "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.49.0.tgz", - "integrity": "sha512-LlKaciDe3GmZFphXIc79THF/YYBugZ7FS1pO581E/edlVVNbZKDy93evqmrfQ9/Y4uN0vVhX4iuchq26mK/iiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.49.0", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/type-utils/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@typescript-eslint/type-utils/node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.49.0.tgz", - "integrity": "sha512-N3W7rJw7Rw+z1tRsHZbK395TWSYvufBXumYtEGzypgMUthlg0/hmCImeA8hgO2d2G4pd7ftpxxul2J8OdtdaFA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.49.0", - "@typescript-eslint/types": "8.49.0", - "@typescript-eslint/typescript-estree": "8.49.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", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.49.0.tgz", - "integrity": "sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.49.0", - "@typescript-eslint/visitor-keys": "8.49.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/utils/node_modules/@typescript-eslint/types": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.49.0.tgz", - "integrity": "sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ==", - "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/utils/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.49.0.tgz", - "integrity": "sha512-jrLdRuAbPfPIdYNppHJ/D0wN+wwNfJ32YTAm10eJVsFmrVpXQnDWBn8niCSMlWjvml8jsce5E/O+86IQtTbJWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.49.0", - "@typescript-eslint/tsconfig-utils": "8.49.0", - "@typescript-eslint/types": "8.49.0", - "@typescript-eslint/visitor-keys": "8.49.0", - "debug": "^4.3.4", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.1.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.0.0" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.49.0.tgz", - "integrity": "sha512-LlKaciDe3GmZFphXIc79THF/YYBugZ7FS1pO581E/edlVVNbZKDy93evqmrfQ9/Y4uN0vVhX4iuchq26mK/iiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.49.0", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "dev": true, - "license": "ISC" - }, - "node_modules/@unrs/resolver-binding-android-arm-eabi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", - "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@unrs/resolver-binding-android-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", - "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@unrs/resolver-binding-darwin-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", - "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@unrs/resolver-binding-darwin-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", - "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@unrs/resolver-binding-freebsd-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", - "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", - "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", - "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", - "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", - "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", - "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", - "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", - "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", - "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-x64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", - "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-x64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", - "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-wasm32-wasi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", - "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^0.2.11" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", - "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", - "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-x64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", - "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "license": "MIT", - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/accepts/node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/accepts/node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, - "license": "MIT", - "peer": true, - "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/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/agentkeepalive": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", - "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", - "license": "MIT", - "dependencies": { - "humanize-ms": "^1.2.1" - }, - "engines": { - "node": ">= 8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "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/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^0.21.3" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "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/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" - }, - "node_modules/asap": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", - "dev": true, - "license": "MIT" - }, - "node_modules/async": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", - "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", - "license": "MIT" - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", - "proxy-from-env": "^1.1.0" - } - }, - "node_modules/babel-jest": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", - "integrity": "sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/transform": "30.2.0", - "@types/babel__core": "^7.20.5", - "babel-plugin-istanbul": "^7.0.1", - "babel-preset-jest": "30.2.0", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "slash": "^3.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.11.0 || ^8.0.0-0" - } - }, - "node_modules/babel-plugin-istanbul": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", - "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", - "dev": true, - "license": "BSD-3-Clause", - "workspaces": [ - "test/babel-8" - ], - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-instrument": "^6.0.2", - "test-exclude": "^6.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/babel-plugin-jest-hoist": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.2.0.tgz", - "integrity": "sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/babel__core": "^7.20.5" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/babel-preset-current-node-syntax": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", - "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/plugin-syntax-async-generators": "^7.8.4", - "@babel/plugin-syntax-bigint": "^7.8.3", - "@babel/plugin-syntax-class-properties": "^7.12.13", - "@babel/plugin-syntax-class-static-block": "^7.14.5", - "@babel/plugin-syntax-import-attributes": "^7.24.7", - "@babel/plugin-syntax-import-meta": "^7.10.4", - "@babel/plugin-syntax-json-strings": "^7.8.3", - "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-numeric-separator": "^7.10.4", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-syntax-private-property-in-object": "^7.14.5", - "@babel/plugin-syntax-top-level-await": "^7.14.5" - }, - "peerDependencies": { - "@babel/core": "^7.0.0 || ^8.0.0-0" - } - }, - "node_modules/babel-preset-jest": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.2.0.tgz", - "integrity": "sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "babel-plugin-jest-hoist": "30.2.0", - "babel-preset-current-node-syntax": "^1.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.11.0 || ^8.0.0-beta.1" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" - }, - "node_modules/base32.js": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.0.1.tgz", - "integrity": "sha512-EGHIRiegFa62/SsA1J+Xs2tIzludPdzM064N9wjbiEgHnGnJ1V0WEpA4pEwCYT5nDvZk3ubf0shqaCS7k6xeUQ==", - "license": "MIT" - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "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/base64url": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", - "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/baseline-browser-mapping": { - "version": "2.9.5", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.5.tgz", - "integrity": "sha512-D5vIoztZOq1XM54LUdttJVc96ggEsIfju2JBvht06pSzpckp3C7HReun67Bghzrtdsq9XdMGbSSB3v3GhMNmAA==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.js" - } - }, - "node_modules/basic-auth": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", - "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "5.1.2" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/basic-auth/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/bcryptjs": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", - "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", - "license": "BSD-3-Clause", - "bin": { - "bcrypt": "bin/bcrypt" - } - }, - "node_modules/bignumber.js": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/body-parser": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", - "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", - "on-finished": "^2.4.1", - "qs": "^6.14.0", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/bowser": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.13.1.tgz", - "integrity": "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==", - "dev": true, - "license": "MIT" - }, - "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "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.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", - "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", - "peer": true, - "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/bs-logger": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", - "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-json-stable-stringify": "2.x" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/bser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", - "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "node-int64": "^0.4.0" - } - }, - "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/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "license": "MIT" - }, - "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/call-me-maybe": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", - "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", - "license": "MIT" - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001759", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001759.tgz", - "integrity": "sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==", - "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/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/char-regex": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", - "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/ci-info": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", - "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cjs-module-lexer": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.1.1.tgz", - "integrity": "sha512-+CmxIZ/L2vNcEfvNtLdU0ZQ6mbq3FZnwAP2PPTiKP+1QOoKwlKlPgb8UKV0Dds7QVaMnHm+FwSft2VB0s/SLjQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/co": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", - "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">= 1.0.0", - "node": ">= 0.12.0" - } - }, - "node_modules/collect-v8-coverage": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", - "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", - "dev": true, - "license": "MIT" - }, - "node_modules/color": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", - "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", - "license": "MIT", - "dependencies": { - "color-convert": "^3.1.3", - "color-string": "^2.1.3" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/color-string": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", - "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", - "license": "MIT", - "dependencies": { - "color-name": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/color-string/node_modules/color-name": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", - "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", - "license": "MIT", - "engines": { - "node": ">=12.20" - } - }, - "node_modules/color/node_modules/color-convert": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", - "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", - "license": "MIT", - "dependencies": { - "color-name": "^2.0.0" - }, - "engines": { - "node": ">=14.6" - } - }, - "node_modules/color/node_modules/color-name": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", - "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", - "license": "MIT", - "engines": { - "node": ">=12.20" - } - }, - "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": "6.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz", - "integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/component-emitter": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", - "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/compressible": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", - "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", - "license": "MIT", - "dependencies": { - "mime-db": ">= 1.43.0 < 2" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/compression": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", - "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", - "license": "MIT", - "dependencies": { - "bytes": "3.1.2", - "compressible": "~2.0.18", - "debug": "2.6.9", - "negotiator": "~0.6.4", - "on-headers": "~1.1.0", - "safe-buffer": "5.2.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/compression/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/compression/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/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "license": "MIT" - }, - "node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "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/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/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.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/cookiejar": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", - "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", - "dev": true, - "license": "MIT" - }, - "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "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/date-fns": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", - "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/kossnocorp" - } - }, - "node_modules/dayjs": { - "version": "1.11.19", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", - "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decamelize": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/dedent": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", - "integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "babel-plugin-macros": "^3.1.0" - }, - "peerDependenciesMeta": { - "babel-plugin-macros": { - "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/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "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/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/detect-newline": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", - "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/dezalgo": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", - "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", - "dev": true, - "license": "ISC", - "dependencies": { - "asap": "^2.0.0", - "wrappy": "1" - } - }, - "node_modules/dijkstrajs": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", - "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", - "license": "MIT" - }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "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/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, - "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/electron-to-chromium": { - "version": "1.5.267", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", - "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", - "dev": true, - "license": "ISC" - }, - "node_modules/emittery": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", - "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sindresorhus/emittery?sponsor=1" - } - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/enabled": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", - "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", - "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/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-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.27.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.1.tgz", - "integrity": "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.1", - "@esbuild/android-arm": "0.27.1", - "@esbuild/android-arm64": "0.27.1", - "@esbuild/android-x64": "0.27.1", - "@esbuild/darwin-arm64": "0.27.1", - "@esbuild/darwin-x64": "0.27.1", - "@esbuild/freebsd-arm64": "0.27.1", - "@esbuild/freebsd-x64": "0.27.1", - "@esbuild/linux-arm": "0.27.1", - "@esbuild/linux-arm64": "0.27.1", - "@esbuild/linux-ia32": "0.27.1", - "@esbuild/linux-loong64": "0.27.1", - "@esbuild/linux-mips64el": "0.27.1", - "@esbuild/linux-ppc64": "0.27.1", - "@esbuild/linux-riscv64": "0.27.1", - "@esbuild/linux-s390x": "0.27.1", - "@esbuild/linux-x64": "0.27.1", - "@esbuild/netbsd-arm64": "0.27.1", - "@esbuild/netbsd-x64": "0.27.1", - "@esbuild/openbsd-arm64": "0.27.1", - "@esbuild/openbsd-x64": "0.27.1", - "@esbuild/openharmony-arm64": "0.27.1", - "@esbuild/sunos-x64": "0.27.1", - "@esbuild/win32-arm64": "0.27.1", - "@esbuild/win32-ia32": "0.27.1", - "@esbuild/win32-x64": "0.27.1" - } - }, - "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-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/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": "9.39.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", - "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.1", - "@eslint/plugin-kit": "^0.4.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.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", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "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/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", - "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==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "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/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/exit-x": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", - "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/expect": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-30.2.0.tgz", - "integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/expect-utils": "30.2.0", - "@jest/get-type": "30.1.0", - "jest-matcher-utils": "30.2.0", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-util": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "license": "MIT", - "peer": true, - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express-rate-limit": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", - "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", - "license": "MIT", - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, - "node_modules/express-validator": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.1.tgz", - "integrity": "sha512-IGenaSf+DnWc69lKuqlRE9/i/2t5/16VpH5bXoqdxWz1aCpRvEdrBuu1y95i/iL5QP8ZYVATiwLFhwk3EDl5vg==", - "license": "MIT", - "dependencies": { - "lodash": "^4.17.21", - "validator": "~13.15.23" - }, - "engines": { - "node": ">= 8.0.0" - } - }, - "node_modules/express/node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT" - }, - "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-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/fast-safe-stringify": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", - "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-xml-parser": { - "version": "5.2.5", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", - "integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "strnum": "^2.1.0" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, - "node_modules/fb-watchman": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", - "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "bser": "2.1.1" - } - }, - "node_modules/fecha": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", - "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", - "license": "MIT" - }, - "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/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "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.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", - "dev": true, - "license": "ISC" - }, - "node_modules/fn.name": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", - "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", - "license": "MIT" - }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/foreground-child/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "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/form-data-encoder": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", - "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", - "license": "MIT" - }, - "node_modules/formdata-node": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", - "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", - "license": "MIT", - "dependencies": { - "node-domexception": "1.0.0", - "web-streams-polyfill": "4.0.0-beta.3" - }, - "engines": { - "node": ">= 12.20" - } - }, - "node_modules/formidable": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.5.tgz", - "integrity": "sha512-Oz5Hwvwak/DCaXVVUtPn4oLMLLy1CdclLKO1LFgU7XzDpVMUU5UjlSLpGMocyQNNk8F6IJW9M/YdooSn2MRI+Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@paralleldrive/cuid2": "^2.2.2", - "dezalgo": "^1.0.4", - "once": "^1.4.0", - "qs": "^6.11.0" - }, - "funding": { - "url": "https://ko-fi.com/tunnckoCore/commissions" - } - }, - "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": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "license": "ISC" - }, - "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/gaxios": { - "version": "6.7.1", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", - "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", - "license": "Apache-2.0", - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^7.0.1", - "is-stream": "^2.0.0", - "node-fetch": "^2.6.9", - "uuid": "^9.0.1" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/gcp-metadata": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", - "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", - "license": "Apache-2.0", - "dependencies": { - "gaxios": "^6.1.1", - "google-logging-utils": "^0.0.2", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=14" - } - }, - "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-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "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-package-type": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", - "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, - "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/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-tsconfig": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", - "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, - "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "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/globals": { - "version": "15.15.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", - "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/google-auth-library": { - "version": "9.15.1", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", - "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", - "license": "Apache-2.0", - "dependencies": { - "base64-js": "^1.3.0", - "ecdsa-sig-formatter": "^1.0.11", - "gaxios": "^6.1.1", - "gcp-metadata": "^6.1.0", - "gtoken": "^7.0.0", - "jws": "^4.0.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/google-logging-utils": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", - "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/gtoken": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", - "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", - "license": "MIT", - "dependencies": { - "gaxios": "^6.0.0", - "jws": "^4.0.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/handlebars": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", - "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.5", - "neo-async": "^2.6.2", - "source-map": "^0.6.1", - "wordwrap": "^1.0.0" - }, - "bin": { - "handlebars": "bin/handlebars" - }, - "engines": { - "node": ">=0.4.7" - }, - "optionalDependencies": { - "uglify-js": "^3.1.4" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "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.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/helmet": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", - "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, - "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/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.17.0" - } - }, - "node_modules/humanize-ms": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.0.0" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", - "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "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/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/import-local": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", - "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pkg-dir": "^4.2.0", - "resolve-cwd": "^3.0.0" - }, - "bin": { - "import-local-fixture": "fixtures/cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "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.", - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "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/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, - "license": "MIT" - }, - "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-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-generator-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", - "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "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/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" - }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-instrument": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", - "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/core": "^7.23.9", - "@babel/parser": "^7.23.9", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-source-maps": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", - "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.23", - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/jest": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest/-/jest-30.2.0.tgz", - "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@jest/core": "30.2.0", - "@jest/types": "30.2.0", - "import-local": "^3.2.0", - "jest-cli": "30.2.0" - }, - "bin": { - "jest": "bin/jest.js" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/jest-changed-files": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.2.0.tgz", - "integrity": "sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "execa": "^5.1.1", - "jest-util": "30.2.0", - "p-limit": "^3.1.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-circus": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.2.0.tgz", - "integrity": "sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "30.2.0", - "@jest/expect": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/types": "30.2.0", - "@types/node": "*", - "chalk": "^4.1.2", - "co": "^4.6.0", - "dedent": "^1.6.0", - "is-generator-fn": "^2.1.0", - "jest-each": "30.2.0", - "jest-matcher-utils": "30.2.0", - "jest-message-util": "30.2.0", - "jest-runtime": "30.2.0", - "jest-snapshot": "30.2.0", - "jest-util": "30.2.0", - "p-limit": "^3.1.0", - "pretty-format": "30.2.0", - "pure-rand": "^7.0.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.6" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-cli": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.2.0.tgz", - "integrity": "sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/core": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/types": "30.2.0", - "chalk": "^4.1.2", - "exit-x": "^0.2.2", - "import-local": "^3.2.0", - "jest-config": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", - "yargs": "^17.7.2" - }, - "bin": { - "jest": "bin/jest.js" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/jest-config": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.2.0.tgz", - "integrity": "sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.27.4", - "@jest/get-type": "30.1.0", - "@jest/pattern": "30.0.1", - "@jest/test-sequencer": "30.2.0", - "@jest/types": "30.2.0", - "babel-jest": "30.2.0", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "deepmerge": "^4.3.1", - "glob": "^10.3.10", - "graceful-fs": "^4.2.11", - "jest-circus": "30.2.0", - "jest-docblock": "30.2.0", - "jest-environment-node": "30.2.0", - "jest-regex-util": "30.0.1", - "jest-resolve": "30.2.0", - "jest-runner": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", - "micromatch": "^4.0.8", - "parse-json": "^5.2.0", - "pretty-format": "30.2.0", - "slash": "^3.0.0", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "@types/node": "*", - "esbuild-register": ">=3.4.0", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "esbuild-register": { - "optional": true - }, - "ts-node": { - "optional": true - } - } - }, - "node_modules/jest-diff": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", - "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/diff-sequences": "30.0.1", - "@jest/get-type": "30.1.0", - "chalk": "^4.1.2", - "pretty-format": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-docblock": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.2.0.tgz", - "integrity": "sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "detect-newline": "^3.1.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-each": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.2.0.tgz", - "integrity": "sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/get-type": "30.1.0", - "@jest/types": "30.2.0", - "chalk": "^4.1.2", - "jest-util": "30.2.0", - "pretty-format": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-environment-node": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.2.0.tgz", - "integrity": "sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "30.2.0", - "@jest/fake-timers": "30.2.0", - "@jest/types": "30.2.0", - "@types/node": "*", - "jest-mock": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-haste-map": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.2.0.tgz", - "integrity": "sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.2.0", - "@types/node": "*", - "anymatch": "^3.1.3", - "fb-watchman": "^2.0.2", - "graceful-fs": "^4.2.11", - "jest-regex-util": "30.0.1", - "jest-util": "30.2.0", - "jest-worker": "30.2.0", - "micromatch": "^4.0.8", - "walker": "^1.0.8" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.3" - } - }, - "node_modules/jest-leak-detector": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.2.0.tgz", - "integrity": "sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/get-type": "30.1.0", - "pretty-format": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-matcher-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz", - "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/get-type": "30.1.0", - "chalk": "^4.1.2", - "jest-diff": "30.2.0", - "pretty-format": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-message-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", - "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@jest/types": "30.2.0", - "@types/stack-utils": "^2.0.3", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "micromatch": "^4.0.8", - "pretty-format": "30.2.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.6" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-mock": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", - "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.2.0", - "@types/node": "*", - "jest-util": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-pnp-resolver": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", - "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "peerDependencies": { - "jest-resolve": "*" - }, - "peerDependenciesMeta": { - "jest-resolve": { - "optional": true - } - } - }, - "node_modules/jest-regex-util": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", - "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-resolve": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.2.0.tgz", - "integrity": "sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", - "jest-pnp-resolver": "^1.2.3", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", - "slash": "^3.0.0", - "unrs-resolver": "^1.7.11" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-resolve-dependencies": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.2.0.tgz", - "integrity": "sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w==", - "dev": true, - "license": "MIT", - "dependencies": { - "jest-regex-util": "30.0.1", - "jest-snapshot": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-runner": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.2.0.tgz", - "integrity": "sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/console": "30.2.0", - "@jest/environment": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", - "@types/node": "*", - "chalk": "^4.1.2", - "emittery": "^0.13.1", - "exit-x": "^0.2.2", - "graceful-fs": "^4.2.11", - "jest-docblock": "30.2.0", - "jest-environment-node": "30.2.0", - "jest-haste-map": "30.2.0", - "jest-leak-detector": "30.2.0", - "jest-message-util": "30.2.0", - "jest-resolve": "30.2.0", - "jest-runtime": "30.2.0", - "jest-util": "30.2.0", - "jest-watcher": "30.2.0", - "jest-worker": "30.2.0", - "p-limit": "^3.1.0", - "source-map-support": "0.5.13" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-runtime": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.2.0.tgz", - "integrity": "sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "30.2.0", - "@jest/fake-timers": "30.2.0", - "@jest/globals": "30.2.0", - "@jest/source-map": "30.0.1", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", - "@types/node": "*", - "chalk": "^4.1.2", - "cjs-module-lexer": "^2.1.0", - "collect-v8-coverage": "^1.0.2", - "glob": "^10.3.10", - "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-regex-util": "30.0.1", - "jest-resolve": "30.2.0", - "jest-snapshot": "30.2.0", - "jest-util": "30.2.0", - "slash": "^3.0.0", - "strip-bom": "^4.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-snapshot": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.2.0.tgz", - "integrity": "sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.27.4", - "@babel/generator": "^7.27.5", - "@babel/plugin-syntax-jsx": "^7.27.1", - "@babel/plugin-syntax-typescript": "^7.27.1", - "@babel/types": "^7.27.3", - "@jest/expect-utils": "30.2.0", - "@jest/get-type": "30.1.0", - "@jest/snapshot-utils": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", - "babel-preset-current-node-syntax": "^1.2.0", - "chalk": "^4.1.2", - "expect": "30.2.0", - "graceful-fs": "^4.2.11", - "jest-diff": "30.2.0", - "jest-matcher-utils": "30.2.0", - "jest-message-util": "30.2.0", - "jest-util": "30.2.0", - "pretty-format": "30.2.0", - "semver": "^7.7.2", - "synckit": "^0.11.8" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", - "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.2.0", - "@types/node": "*", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "graceful-fs": "^4.2.11", - "picomatch": "^4.0.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-util/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/jest-validate": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.2.0.tgz", - "integrity": "sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/get-type": "30.1.0", - "@jest/types": "30.2.0", - "camelcase": "^6.3.0", - "chalk": "^4.1.2", - "leven": "^3.1.0", - "pretty-format": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-validate/node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/jest-watcher": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.2.0.tgz", - "integrity": "sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/test-result": "30.2.0", - "@jest/types": "30.2.0", - "@types/node": "*", - "ansi-escapes": "^4.3.2", - "chalk": "^4.1.2", - "emittery": "^0.13.1", - "jest-util": "30.2.0", - "string-length": "^4.0.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-worker": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.2.0.tgz", - "integrity": "sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@ungap/structured-clone": "^1.3.0", - "jest-util": "30.2.0", - "merge-stream": "^2.0.0", - "supports-color": "^8.1.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "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==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "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-bigint": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", - "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", - "license": "MIT", - "dependencies": { - "bignumber.js": "^9.0.0" - } - }, - "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-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-to-ts": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", - "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.3", - "ts-algebra": "^2.0.0" - }, - "engines": { - "node": ">=16" - } - }, - "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/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/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/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/kuler": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", - "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", - "license": "MIT" - }, - "node_modules/leven": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", - "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "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/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/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "license": "MIT" - }, - "node_modules/lodash.get": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", - "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", - "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", - "license": "MIT" - }, - "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.isequal": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", - "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", - "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", - "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.memoize": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", - "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.mergewith": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", - "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", - "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/logform": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", - "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", - "license": "MIT", - "dependencies": { - "@colors/colors": "1.6.0", - "@types/triple-beam": "^1.3.2", - "fecha": "^4.2.0", - "ms": "^2.1.1", - "safe-stable-stringify": "^2.3.1", - "triple-beam": "^1.3.0" - }, - "engines": { - "node": ">= 12.0.0" - } - }, - "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/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, - "license": "ISC" - }, - "node_modules/makeerror": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", - "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tmpl": "1.0.5" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true, - "license": "MIT" - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "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.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "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/mime-types/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/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/morgan": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", - "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", - "license": "MIT", - "dependencies": { - "basic-auth": "~2.0.1", - "debug": "2.6.9", - "depd": "~2.0.0", - "on-finished": "~2.3.0", - "on-headers": "~1.1.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/morgan/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/morgan/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/morgan/node_modules/on-finished": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "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/napi-postinstall": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", - "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", - "dev": true, - "license": "MIT", - "bin": { - "napi-postinstall": "lib/cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/napi-postinstall" - } - }, - "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/negotiator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", - "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "engines": { - "node": ">=10.5.0" - } - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-int64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", - "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nodemailer": { - "version": "7.0.11", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.11.tgz", - "integrity": "sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==", - "license": "MIT-0", - "engines": { - "node": ">=6.0.0" - } - }, - "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/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/oauth": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", - "integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==", - "license": "MIT" - }, - "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==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "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/on-headers": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", - "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/one-time": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", - "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", - "license": "MIT", - "dependencies": { - "fn.name": "1.x.x" - } - }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/openai": { - "version": "4.104.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz", - "integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==", - "license": "Apache-2.0", - "dependencies": { - "@types/node": "^18.11.18", - "@types/node-fetch": "^2.6.4", - "abort-controller": "^3.0.0", - "agentkeepalive": "^4.2.1", - "form-data-encoder": "1.7.2", - "formdata-node": "^4.3.2", - "node-fetch": "^2.6.7" - }, - "bin": { - "openai": "bin/cli" - }, - "peerDependencies": { - "ws": "^8.18.0", - "zod": "^3.23.8" - }, - "peerDependenciesMeta": { - "ws": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, - "node_modules/openai/node_modules/@types/node": { - "version": "18.19.130", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", - "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", - "license": "MIT", - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/openai/node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "license": "MIT" - }, - "node_modules/openapi-types": { - "version": "12.1.3", - "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", - "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", - "license": "MIT", - "peer": true - }, - "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/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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/passport": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", - "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", - "license": "MIT", - "dependencies": { - "passport-strategy": "1.x.x", - "pause": "0.0.1", - "utils-merge": "^1.0.1" - }, - "engines": { - "node": ">= 0.4.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/jaredhanson" - } - }, - "node_modules/passport-apple": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/passport-apple/-/passport-apple-2.0.2.tgz", - "integrity": "sha512-JRXomYvirWeIq11pa/SwhXXxekFWoukMcQu45BDl3Kw5WobtWF0iw99vpkBwPEpdaou0DDSq4udxR34T6eZkdw==", - "license": "MIT", - "dependencies": { - "jsonwebtoken": "^9.0.0", - "passport-oauth2": "^1.6.1" - } - }, - "node_modules/passport-facebook": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/passport-facebook/-/passport-facebook-3.0.0.tgz", - "integrity": "sha512-K/qNzuFsFISYAyC1Nma4qgY/12V3RSLFdFVsPKXiKZt434wOvthFW1p7zKa1iQihQMRhaWorVE1o3Vi1o+ZgeQ==", - "license": "MIT", - "dependencies": { - "passport-oauth2": "1.x.x" - }, - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/passport-github2": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/passport-github2/-/passport-github2-0.1.12.tgz", - "integrity": "sha512-3nPUCc7ttF/3HSP/k9sAXjz3SkGv5Nki84I05kSQPo01Jqq1NzJACgMblCK0fGcv9pKCG/KXU3AJRDGLqHLoIw==", - "dependencies": { - "passport-oauth2": "1.x.x" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/passport-google-oauth20": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", - "integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==", - "license": "MIT", - "dependencies": { - "passport-oauth2": "1.x.x" - }, - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/passport-local": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz", - "integrity": "sha512-9wCE6qKznvf9mQYYbgJ3sVOHmCWoUNMVFoZzNoznmISbhnNNPhN9xfY3sLmScHMetEJeoY7CXwfhCe7argfQow==", - "dependencies": { - "passport-strategy": "1.x.x" - }, - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/passport-oauth2": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", - "integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==", - "license": "MIT", - "dependencies": { - "base64url": "3.x.x", - "oauth": "0.10.x", - "passport-strategy": "1.x.x", - "uid2": "0.0.x", - "utils-merge": "1.x.x" - }, - "engines": { - "node": ">= 0.4.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/jaredhanson" - } - }, - "node_modules/passport-strategy": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", - "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", - "engines": { - "node": ">= 0.4.0" - } - }, - "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==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "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-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/pause": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", - "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" - }, - "node_modules/pg": { - "version": "8.16.3", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", - "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", - "license": "MIT", - "peer": true, - "dependencies": { - "pg-connection-string": "^2.9.1", - "pg-pool": "^3.10.1", - "pg-protocol": "^1.10.3", - "pg-types": "2.2.0", - "pgpass": "1.0.5" - }, - "engines": { - "node": ">= 16.0.0" - }, - "optionalDependencies": { - "pg-cloudflare": "^1.2.7" - }, - "peerDependencies": { - "pg-native": ">=3.0.1" - }, - "peerDependenciesMeta": { - "pg-native": { - "optional": true - } - } - }, - "node_modules/pg-cloudflare": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz", - "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==", - "license": "MIT", - "optional": true - }, - "node_modules/pg-connection-string": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz", - "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==", - "license": "MIT" - }, - "node_modules/pg-int8": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", - "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", - "license": "ISC", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/pg-pool": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz", - "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", - "license": "MIT", - "peerDependencies": { - "pg": ">=8.0" - } - }, - "node_modules/pg-protocol": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", - "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", - "license": "MIT" - }, - "node_modules/pg-types": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", - "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", - "license": "MIT", - "dependencies": { - "pg-int8": "1.0.1", - "postgres-array": "~2.0.0", - "postgres-bytea": "~1.0.0", - "postgres-date": "~1.0.4", - "postgres-interval": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/pgpass": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", - "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", - "license": "MIT", - "dependencies": { - "split2": "^4.1.0" - } - }, - "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.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "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/pkg-dir": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", - "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "find-up": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pkg-dir/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pngjs": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", - "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", - "license": "MIT", - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/postgres-array": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", - "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/postgres-bytea": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", - "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-date": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", - "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-interval": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", - "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", - "license": "MIT", - "dependencies": { - "xtend": "^4.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "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/prettier": { - "version": "3.7.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", - "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/pretty-format": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", - "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "30.0.5", - "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "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/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, - "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/pure-rand": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", - "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT" - }, - "node_modules/qrcode": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", - "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", - "license": "MIT", - "dependencies": { - "dijkstrajs": "^1.0.1", - "pngjs": "^5.0.0", - "yargs": "^15.3.1" - }, - "bin": { - "qrcode": "bin/qrcode" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/qrcode/node_modules/cliui": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", - "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^6.2.0" - } - }, - "node_modules/qrcode/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/qrcode/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/y18n": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", - "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", - "license": "ISC" - }, - "node_modules/qrcode/node_modules/yargs": { - "version": "15.4.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", - "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", - "license": "MIT", - "dependencies": { - "cliui": "^6.0.0", - "decamelize": "^1.2.0", - "find-up": "^4.1.0", - "get-caller-file": "^2.0.1", - "require-directory": "^2.1.1", - "require-main-filename": "^2.0.0", - "set-blocking": "^2.0.0", - "string-width": "^4.2.0", - "which-module": "^2.0.0", - "y18n": "^4.0.0", - "yargs-parser": "^18.1.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/qrcode/node_modules/yargs-parser": { - "version": "18.1.3", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", - "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", - "license": "ISC", - "dependencies": { - "camelcase": "^5.0.0", - "decamelize": "^1.2.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/querystringify": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "license": "MIT" - }, - "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": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/require-main-filename": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", - "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", - "license": "ISC" - }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "license": "MIT" - }, - "node_modules/resolve-cwd": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", - "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve-cwd/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "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/safe-stable-stringify": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", - "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "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/scmp": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/scmp/-/scmp-2.1.0.tgz", - "integrity": "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==", - "license": "BSD-3-Clause" - }, - "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/send": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", - "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", - "license": "MIT", - "dependencies": { - "debug": "^4.3.5", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "mime-types": "^3.0.1", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/send/node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "license": "ISC" - }, - "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/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/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/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-support": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", - "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/speakeasy": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/speakeasy/-/speakeasy-2.0.0.tgz", - "integrity": "sha512-lW2A2s5LKi8rwu77ewisuUOtlCydF/hmQSOJjpTqTj1gZLkNgTaYnyvfxy2WBr4T/h+9c4g8HIITfj83OkFQFw==", - "license": "MIT", - "dependencies": { - "base32.js": "0.0.1" - }, - "engines": { - "node": ">= 0.10.0" - } - }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "license": "ISC", - "engines": { - "node": ">= 10.x" - } - }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/stack-trace": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", - "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/stack-utils": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", - "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/stack-utils/node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "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/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/string-length": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", - "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "char-regex": "^1.0.2", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-bom": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", - "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/stripe": { - "version": "17.7.0", - "resolved": "https://registry.npmjs.org/stripe/-/stripe-17.7.0.tgz", - "integrity": "sha512-aT2BU9KkizY9SATf14WhhYVv2uOapBWX0OFWF4xvcj1mPaNotlSc2CsxpS4DS46ZueSppmCF5BX1sNYBtwBvfw==", - "license": "MIT", - "dependencies": { - "@types/node": ">=8.1.0", - "qs": "^6.11.0" - }, - "engines": { - "node": ">=12.*" - } - }, - "node_modules/strnum": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz", - "integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, - "node_modules/superagent": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.1.2.tgz", - "integrity": "sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==", - "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", - "dev": true, - "license": "MIT", - "dependencies": { - "component-emitter": "^1.3.0", - "cookiejar": "^2.1.4", - "debug": "^4.3.4", - "fast-safe-stringify": "^2.1.1", - "form-data": "^4.0.0", - "formidable": "^2.1.2", - "methods": "^1.1.2", - "mime": "2.6.0", - "qs": "^6.11.0", - "semver": "^7.3.8" - }, - "engines": { - "node": ">=6.4.0 <13 || >=14" - } - }, - "node_modules/superagent/node_modules/mime": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "dev": true, - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/supertest": { - "version": "6.3.4", - "resolved": "https://registry.npmjs.org/supertest/-/supertest-6.3.4.tgz", - "integrity": "sha512-erY3HFDG0dPnhw4U+udPfrzXa4xhSG+n4rxfRuZWCUvjFWwKl+OxWf/7zk50s84/fAAs7vf5QAb9uRa0cCykxw==", - "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", - "dev": true, - "license": "MIT", - "dependencies": { - "methods": "^1.1.2", - "superagent": "^8.1.2" - }, - "engines": { - "node": ">=6.4.0" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/swagger-jsdoc": { - "version": "6.2.8", - "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz", - "integrity": "sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==", - "license": "MIT", - "dependencies": { - "commander": "6.2.0", - "doctrine": "3.0.0", - "glob": "7.1.6", - "lodash.mergewith": "^4.6.2", - "swagger-parser": "^10.0.3", - "yaml": "2.0.0-1" - }, - "bin": { - "swagger-jsdoc": "bin/swagger-jsdoc.js" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/swagger-jsdoc/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/swagger-jsdoc/node_modules/glob": { - "version": "7.1.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", - "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/swagger-jsdoc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/swagger-parser": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz", - "integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==", - "license": "MIT", - "dependencies": { - "@apidevtools/swagger-parser": "10.0.3" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/swagger-ui-dist": { - "version": "5.31.0", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.31.0.tgz", - "integrity": "sha512-zSUTIck02fSga6rc0RZP3b7J7wgHXwLea8ZjgLA3Vgnb8QeOl3Wou2/j5QkzSGeoz6HusP/coYuJl33aQxQZpg==", - "license": "Apache-2.0", - "dependencies": { - "@scarf/scarf": "=1.4.0" - } - }, - "node_modules/swagger-ui-express": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", - "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", - "license": "MIT", - "dependencies": { - "swagger-ui-dist": ">=5.0.0" - }, - "engines": { - "node": ">= v0.10.32" - }, - "peerDependencies": { - "express": ">=4.0.0 || >=5.0.0-beta" - } - }, - "node_modules/synckit": { - "version": "0.11.11", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", - "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@pkgr/core": "^0.2.9" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/synckit" - } - }, - "node_modules/test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/test-exclude/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/test-exclude/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/text-hex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", - "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "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.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/tmpl": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", - "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", - "dev": true, - "license": "BSD-3-Clause" - }, - "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/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/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, - "node_modules/triple-beam": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", - "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", - "license": "MIT", - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/ts-algebra": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", - "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", - "license": "MIT" - }, - "node_modules/ts-jest": { - "version": "29.4.6", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz", - "integrity": "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bs-logger": "^0.2.6", - "fast-json-stable-stringify": "^2.1.0", - "handlebars": "^4.7.8", - "json5": "^2.2.3", - "lodash.memoize": "^4.1.2", - "make-error": "^1.3.6", - "semver": "^7.7.3", - "type-fest": "^4.41.0", - "yargs-parser": "^21.1.1" - }, - "bin": { - "ts-jest": "cli.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" - }, - "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", - "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 - } - } - }, - "node_modules/ts-jest/node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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==", - "dev": true, - "license": "0BSD" - }, - "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/twilio": { - "version": "4.23.0", - "resolved": "https://registry.npmjs.org/twilio/-/twilio-4.23.0.tgz", - "integrity": "sha512-LdNBQfOe0dY2oJH2sAsrxazpgfFQo5yXGxe96QA8UWB5uu+433PrUbkv8gQ5RmrRCqUTPQ0aOrIyAdBr1aB03Q==", - "license": "MIT", - "dependencies": { - "axios": "^1.6.0", - "dayjs": "^1.11.9", - "https-proxy-agent": "^5.0.0", - "jsonwebtoken": "^9.0.0", - "qs": "^6.9.4", - "scmp": "^2.1.0", - "url-parse": "^1.5.9", - "xmlbuilder": "^13.0.2" - }, - "engines": { - "node": ">=14.0" - } - }, - "node_modules/twilio/node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/twilio/node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "license": "MIT", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/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/type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/type-is/node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "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", - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/typescript-eslint": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.49.0.tgz", - "integrity": "sha512-zRSVH1WXD0uXczCXw+nsdjGPUdx4dfrs5VQoHnUWmv1U3oNlAKv4FUNdLDhVUg+gYn+a5hUESqch//Rv5wVhrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.49.0", - "@typescript-eslint/parser": "8.49.0", - "@typescript-eslint/typescript-estree": "8.49.0", - "@typescript-eslint/utils": "8.49.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", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.49.0.tgz", - "integrity": "sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.49.0", - "@typescript-eslint/type-utils": "8.49.0", - "@typescript-eslint/utils": "8.49.0", - "@typescript-eslint/visitor-keys": "8.49.0", - "ignore": "^7.0.0", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.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.49.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.49.0.tgz", - "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@typescript-eslint/scope-manager": "8.49.0", - "@typescript-eslint/types": "8.49.0", - "@typescript-eslint/typescript-estree": "8.49.0", - "@typescript-eslint/visitor-keys": "8.49.0", - "debug": "^4.3.4" - }, - "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", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/scope-manager": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.49.0.tgz", - "integrity": "sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.49.0", - "@typescript-eslint/visitor-keys": "8.49.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/node_modules/@typescript-eslint/types": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.49.0.tgz", - "integrity": "sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ==", - "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/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.49.0.tgz", - "integrity": "sha512-jrLdRuAbPfPIdYNppHJ/D0wN+wwNfJ32YTAm10eJVsFmrVpXQnDWBn8niCSMlWjvml8jsce5E/O+86IQtTbJWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.49.0", - "@typescript-eslint/tsconfig-utils": "8.49.0", - "@typescript-eslint/types": "8.49.0", - "@typescript-eslint/visitor-keys": "8.49.0", - "debug": "^4.3.4", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.1.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.0.0" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.49.0.tgz", - "integrity": "sha512-LlKaciDe3GmZFphXIc79THF/YYBugZ7FS1pO581E/edlVVNbZKDy93evqmrfQ9/Y4uN0vVhX4iuchq26mK/iiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.49.0", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/typescript-eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/typescript-eslint/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/typescript-eslint/node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "node_modules/uglify-js": { - "version": "3.19.3", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", - "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "bin": { - "uglifyjs": "bin/uglifyjs" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/uid2": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", - "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==", - "license": "MIT" - }, - "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==", - "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/unrs-resolver": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", - "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "napi-postinstall": "^0.3.0" - }, - "funding": { - "url": "https://opencollective.com/unrs-resolver" - }, - "optionalDependencies": { - "@unrs/resolver-binding-android-arm-eabi": "1.11.1", - "@unrs/resolver-binding-android-arm64": "1.11.1", - "@unrs/resolver-binding-darwin-arm64": "1.11.1", - "@unrs/resolver-binding-darwin-x64": "1.11.1", - "@unrs/resolver-binding-freebsd-x64": "1.11.1", - "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", - "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", - "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", - "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", - "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", - "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-x64-musl": "1.11.1", - "@unrs/resolver-binding-wasm32-wasi": "1.11.1", - "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", - "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", - "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz", - "integrity": "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==", - "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/url-parse": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", - "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "license": "MIT", - "dependencies": { - "querystringify": "^2.1.1", - "requires-port": "^1.0.0" - } - }, - "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==", - "license": "MIT" - }, - "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/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/v8-to-istanbul": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", - "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", - "dev": true, - "license": "ISC", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.12", - "@types/istanbul-lib-coverage": "^2.0.1", - "convert-source-map": "^2.0.0" - }, - "engines": { - "node": ">=10.12.0" - } - }, - "node_modules/validator": { - "version": "13.15.23", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.23.tgz", - "integrity": "sha512-4yoz1kEWqUjzi5zsPbAS/903QXSYp0UOtHsPpp7p9rHAw/W+dkInskAE386Fat3oKRROwO98d9ZB0G4cObgUyw==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "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/walker": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", - "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "makeerror": "1.0.12" - } - }, - "node_modules/web-streams-polyfill": { - "version": "4.0.0-beta.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", - "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "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/which-module": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", - "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", - "license": "ISC" - }, - "node_modules/winston": { - "version": "3.19.0", - "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", - "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", - "license": "MIT", - "dependencies": { - "@colors/colors": "^1.6.0", - "@dabh/diagnostics": "^2.0.8", - "async": "^3.2.3", - "is-stream": "^2.0.0", - "logform": "^2.7.0", - "one-time": "^1.0.0", - "readable-stream": "^3.4.0", - "safe-stable-stringify": "^2.3.1", - "stack-trace": "0.0.x", - "triple-beam": "^1.3.0", - "winston-transport": "^4.9.0" - }, - "engines": { - "node": ">= 12.0.0" - } - }, - "node_modules/winston-transport": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", - "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", - "license": "MIT", - "dependencies": { - "logform": "^2.7.0", - "readable-stream": "^3.6.2", - "triple-beam": "^1.3.0" - }, - "engines": { - "node": ">= 12.0.0" - } - }, - "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/wordwrap": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", - "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, - "node_modules/write-file-atomic": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", - "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/write-file-atomic/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/xmlbuilder": { - "version": "13.0.2", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-13.0.2.tgz", - "integrity": "sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ==", - "license": "MIT", - "engines": { - "node": ">=6.0" - } - }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "license": "MIT", - "engines": { - "node": ">=0.4" - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "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/yaml": { - "version": "2.0.0-1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz", - "integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==", - "license": "ISC", - "engines": { - "node": ">= 6" - } - }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "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/z-schema": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz", - "integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==", - "license": "MIT", - "dependencies": { - "lodash.get": "^4.4.2", - "lodash.isequal": "^4.5.0", - "validator": "^13.7.0" - }, - "bin": { - "z-schema": "bin/z-schema" - }, - "engines": { - "node": ">=8.0.0" - }, - "optionalDependencies": { - "commander": "^9.4.1" - } - }, - "node_modules/z-schema/node_modules/commander": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", - "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": "^12.20.0 || >=14" - } - }, - "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "peer": true, - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/apps/backend/package.json b/apps/backend/package.json deleted file mode 100644 index 5f54fcc..0000000 --- a/apps/backend/package.json +++ /dev/null @@ -1,90 +0,0 @@ -{ - "name": "@orbiquant/backend", - "version": "0.1.0", - "description": "OrbiQuant IA - Backend API", - "main": "dist/index.js", - "scripts": { - "dev": "tsx watch src/index.ts", - "build": "tsc", - "start": "node dist/index.js", - "lint": "eslint src", - "lint:fix": "eslint src --fix", - "format": "prettier --write \"src/**/*.ts\"", - "test": "jest --passWithNoTests", - "test:watch": "jest --watch", - "test:coverage": "jest --coverage --passWithNoTests", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@anthropic-ai/sdk": "^0.71.2", - "axios": "^1.6.2", - "bcryptjs": "^3.0.3", - "compression": "^1.7.4", - "cors": "^2.8.5", - "date-fns": "^4.1.0", - "dotenv": "^16.4.7", - "express": "^5.0.1", - "express-rate-limit": "^7.5.0", - "express-validator": "^7.0.1", - "google-auth-library": "^9.4.1", - "helmet": "^8.1.0", - "jsonwebtoken": "^9.0.2", - "morgan": "^1.10.0", - "nodemailer": "^7.0.11", - "openai": "^4.104.0", - "passport": "^0.7.0", - "passport-apple": "^2.0.2", - "passport-facebook": "^3.0.0", - "passport-github2": "^0.1.12", - "passport-google-oauth20": "^2.0.0", - "passport-local": "^1.0.0", - "pg": "^8.11.3", - "qrcode": "^1.5.3", - "speakeasy": "^2.0.0", - "stripe": "^17.5.0", - "swagger-jsdoc": "^6.2.8", - "swagger-ui-express": "^5.0.1", - "twilio": "^4.19.3", - "uuid": "^9.0.1", - "winston": "^3.11.0", - "ws": "^8.18.0", - "zod": "^3.22.4" - }, - "devDependencies": { - "@eslint/js": "^9.17.0", - "@types/bcryptjs": "^2.4.6", - "@types/compression": "^1.7.5", - "@types/cors": "^2.8.17", - "@types/express": "^5.0.0", - "@types/jest": "^30.0.0", - "@types/jsonwebtoken": "^9.0.5", - "@types/morgan": "^1.9.9", - "@types/node": "^20.10.4", - "@types/nodemailer": "^6.4.14", - "@types/passport": "^1.0.16", - "@types/passport-facebook": "^3.0.3", - "@types/passport-github2": "^1.2.9", - "@types/passport-google-oauth20": "^2.0.14", - "@types/passport-local": "^1.0.38", - "@types/pg": "^8.10.9", - "@types/qrcode": "^1.5.5", - "@types/speakeasy": "^2.0.10", - "@types/supertest": "^2.0.16", - "@types/swagger-jsdoc": "^6.0.4", - "@types/swagger-ui-express": "^4.1.8", - "@types/uuid": "^9.0.7", - "@types/ws": "^8.5.13", - "eslint": "^9.17.0", - "globals": "^15.14.0", - "jest": "^30.0.0", - "prettier": "^3.1.1", - "supertest": "^6.3.3", - "ts-jest": "^29.3.0", - "tsx": "^4.6.2", - "typescript": "^5.3.3", - "typescript-eslint": "^8.18.0" - }, - "engines": { - "node": ">=18.0.0" - } -} diff --git a/apps/backend/service.descriptor.yml b/apps/backend/service.descriptor.yml deleted file mode 100644 index b89efb7..0000000 --- a/apps/backend/service.descriptor.yml +++ /dev/null @@ -1,54 +0,0 @@ -# ============================================================================== -# SERVICE DESCRIPTOR - TRADING PLATFORM API -# ============================================================================== -version: "1.0.0" - -service: - name: "trading-api" - display_name: "Trading Platform API" - description: "API para plataforma de trading" - type: "backend" - runtime: "node" - framework: "nestjs" - owner_agent: "NEXUS-BACKEND" - -ports: - internal: 3040 - registry_ref: "projects.trading.services.api" - protocol: "http" - -database: - registry_ref: "trading" - role: "runtime" - -modules: - market_data: - description: "Datos de mercado" - status: "planned" - alerts: - description: "Sistema de alertas" - status: "planned" - portfolio: - description: "Gestion de portafolio" - status: "planned" - -docker: - networks: - - "trading_${ENV:-local}" - - "infra_shared" - labels: - traefik: - enable: true - rule: "Host(`api.trading.localhost`)" - -healthcheck: - endpoint: "/health" - -status: - phase: "planned" - version: "0.0.1" - completeness: 5 - -metadata: - created_at: "2025-12-18" - project: "trading-platform" diff --git a/apps/backend/src/__tests__/jest-migration.test.ts b/apps/backend/src/__tests__/jest-migration.test.ts deleted file mode 100644 index 2d9d722..0000000 --- a/apps/backend/src/__tests__/jest-migration.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Jest 30 Migration Test - * - * This test verifies that Jest 30 is working correctly after migration. - * It tests new features and ensures deprecated methods are not being used. - */ - -describe('Jest 30 Migration', () => { - test('should pass with Jest 30', () => { - expect(true).toBe(true); - }); - - test('should support modern Jest matchers', () => { - const mockFn = jest.fn(); - mockFn('test'); - - // Using toHaveBeenCalled instead of deprecated toBeCalled - expect(mockFn).toHaveBeenCalled(); - expect(mockFn).toHaveBeenCalledWith('test'); - }); - - test('should work with async tests', async () => { - const promise = Promise.resolve('success'); - await expect(promise).resolves.toBe('success'); - }); - - test('should support mock functions', () => { - const mockCallback = jest.fn((x) => x * 2); - - [1, 2, 3].forEach(mockCallback); - - expect(mockCallback).toHaveBeenCalledTimes(3); - expect(mockCallback.mock.results[0].value).toBe(2); - }); -}); diff --git a/apps/backend/src/__tests__/mocks/database.mock.ts b/apps/backend/src/__tests__/mocks/database.mock.ts deleted file mode 100644 index 956d826..0000000 --- a/apps/backend/src/__tests__/mocks/database.mock.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Database Mock for Testing - * - * Provides mock implementations of database operations. - */ - -import { QueryResult, PoolClient } from 'pg'; - -/** - * Mock database query results - */ -export const createMockQueryResult = (rows: T[] = []): QueryResult => ({ - rows, - command: 'SELECT', - rowCount: rows.length, - oid: 0, - fields: [], -}); - -/** - * Mock PoolClient for transaction testing - */ -export const createMockPoolClient = (): jest.Mocked => ({ - query: jest.fn(), - release: jest.fn(), - connect: jest.fn(), - on: jest.fn(), - removeListener: jest.fn(), - emit: jest.fn(), - eventNames: jest.fn(), - listenerCount: jest.fn(), - listeners: jest.fn(), - off: jest.fn(), - addListener: jest.fn(), - once: jest.fn(), - prependListener: jest.fn(), - prependOnceListener: jest.fn(), - removeAllListeners: jest.fn(), - setMaxListeners: jest.fn(), - getMaxListeners: jest.fn(), - rawListeners: jest.fn(), - // eslint-disable-next-line @typescript-eslint/no-explicit-any -} as any); - -/** - * Mock database instance - */ -export const mockDb = { - query: jest.fn(), - getClient: jest.fn(), - transaction: jest.fn(), - healthCheck: jest.fn(), - close: jest.fn(), - getPoolStatus: jest.fn(), -}; - -/** - * Setup database mock with default behaviors - */ -export const setupDatabaseMock = () => { - const mockClient = createMockPoolClient(); - - // Default implementations - mockDb.query.mockResolvedValue(createMockQueryResult([])); - mockDb.getClient.mockResolvedValue(mockClient); - mockDb.transaction.mockImplementation(async (callback) => { - return callback(mockClient); - }); - mockDb.healthCheck.mockResolvedValue(true); - mockDb.getPoolStatus.mockReturnValue({ - total: 10, - idle: 5, - waiting: 0, - }); - - // Mock client methods - mockClient.query.mockResolvedValue(createMockQueryResult([])); - - return { mockDb, mockClient }; -}; - -/** - * Reset all database mocks - */ -export const resetDatabaseMocks = () => { - mockDb.query.mockClear(); - mockDb.getClient.mockClear(); - mockDb.transaction.mockClear(); - mockDb.healthCheck.mockClear(); - mockDb.close.mockClear(); - mockDb.getPoolStatus.mockClear(); -}; - -// Export for use in test files -export { mockDb }; - -// Note: Tests should import mockDb and manually mock the database module -// in their test file using: -// jest.mock('path/to/database', () => ({ -// db: mockDb, -// })); diff --git a/apps/backend/src/__tests__/mocks/email.mock.ts b/apps/backend/src/__tests__/mocks/email.mock.ts deleted file mode 100644 index d1679d5..0000000 --- a/apps/backend/src/__tests__/mocks/email.mock.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Email Mock for Testing - * - * Provides mock implementations for nodemailer. - */ - -/** - * Mock sent emails storage - */ -export const sentEmails: Array<{ - from: string; - to: string; - subject: string; - html: string; - timestamp: Date; -}> = []; - -/** - * Mock transporter - */ -export const mockTransporter = { - sendMail: jest.fn().mockImplementation((mailOptions) => { - sentEmails.push({ - from: mailOptions.from, - to: mailOptions.to, - subject: mailOptions.subject, - html: mailOptions.html, - timestamp: new Date(), - }); - - return Promise.resolve({ - messageId: `mock-message-${Date.now()}@example.com`, - accepted: [mailOptions.to], - rejected: [], - response: '250 Message accepted', - }); - }), - - verify: jest.fn().mockResolvedValue(true), -}; - -/** - * Mock nodemailer - */ -export const mockNodemailer = { - createTransport: jest.fn().mockReturnValue(mockTransporter), -}; - -/** - * Reset email mocks - */ -export const resetEmailMocks = () => { - sentEmails.length = 0; - mockTransporter.sendMail.mockClear(); - mockTransporter.verify.mockClear(); - mockNodemailer.createTransport.mockClear(); -}; - -/** - * Get sent emails - */ -export const getSentEmails = () => sentEmails; - -/** - * Find email by recipient - */ -export const findEmailByRecipient = (email: string) => { - return sentEmails.find((e) => e.to === email); -}; - -/** - * Find email by subject - */ -export const findEmailBySubject = (subject: string) => { - return sentEmails.find((e) => e.subject.includes(subject)); -}; - -// Mock nodemailer module -jest.mock('nodemailer', () => mockNodemailer); diff --git a/apps/backend/src/__tests__/mocks/redis.mock.ts b/apps/backend/src/__tests__/mocks/redis.mock.ts deleted file mode 100644 index 255860f..0000000 --- a/apps/backend/src/__tests__/mocks/redis.mock.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Redis Mock for Testing - * - * Provides mock implementations for Redis operations. - */ - -/** - * In-memory store for testing Redis operations - */ -class MockRedisStore { - private store = new Map(); - - async get(key: string): Promise { - const entry = this.store.get(key); - if (!entry) return null; - - if (entry.expiresAt && Date.now() > entry.expiresAt) { - this.store.delete(key); - return null; - } - - return entry.value; - } - - async setex(key: string, seconds: number, value: string): Promise { - this.store.set(key, { - value, - expiresAt: Date.now() + seconds * 1000, - }); - return 'OK'; - } - - async set(key: string, value: string): Promise { - this.store.set(key, { - value, - expiresAt: null, - }); - return 'OK'; - } - - async del(key: string): Promise { - const deleted = this.store.delete(key); - return deleted ? 1 : 0; - } - - async exists(key: string): Promise { - const entry = this.store.get(key); - if (!entry) return 0; - - if (entry.expiresAt && Date.now() > entry.expiresAt) { - this.store.delete(key); - return 0; - } - - return 1; - } - - async ttl(key: string): Promise { - const entry = this.store.get(key); - if (!entry) return -2; - if (!entry.expiresAt) return -1; - - const remaining = Math.floor((entry.expiresAt - Date.now()) / 1000); - return remaining > 0 ? remaining : -2; - } - - async flushall(): Promise { - this.store.clear(); - return 'OK'; - } - - async quit(): Promise { - this.store.clear(); - return 'OK'; - } - - clear() { - this.store.clear(); - } - - // For debugging - getStore() { - return this.store; - } -} - -/** - * Export singleton instance - */ -export const mockRedisClient = new MockRedisStore(); - -/** - * Reset mock Redis store - */ -export const resetRedisMock = () => { - mockRedisClient.clear(); -}; diff --git a/apps/backend/src/__tests__/setup.ts b/apps/backend/src/__tests__/setup.ts deleted file mode 100644 index bee763f..0000000 --- a/apps/backend/src/__tests__/setup.ts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * Jest Test Setup - * - * Global test configuration and environment setup for all test suites. - * This file runs before all tests. - */ - -// Set test environment -process.env.NODE_ENV = 'test'; - -// Set test config values -process.env.JWT_ACCESS_SECRET = 'test-access-secret'; -process.env.JWT_REFRESH_SECRET = 'test-refresh-secret'; -process.env.JWT_ACCESS_EXPIRES = '15m'; -process.env.JWT_REFRESH_EXPIRES = '7d'; -process.env.DB_HOST = 'localhost'; -process.env.DB_PORT = '5432'; -process.env.DB_NAME = 'test_db'; -process.env.DB_USER = 'test_user'; -process.env.DB_PASSWORD = 'test_password'; -process.env.FRONTEND_URL = 'http://localhost:3000'; -process.env.EMAIL_HOST = 'smtp.test.com'; -process.env.EMAIL_PORT = '587'; -process.env.EMAIL_FROM = 'test@test.com'; - -// Configure test timeouts -jest.setTimeout(10000); // 10 seconds - -// Mock logger to prevent console spam during tests -jest.mock('../shared/utils/logger', () => ({ - logger: { - info: jest.fn(), - error: jest.fn(), - warn: jest.fn(), - debug: jest.fn(), - }, -})); - -// Global test utilities -global.testUtils = { - /** - * Generate a valid test email - */ - generateTestEmail: () => `test-${Date.now()}@example.com`, - - /** - * Generate a strong test password - */ - generateTestPassword: () => 'TestPass123!', - - /** - * Create a mock user object - */ - createMockUser: (overrides = {}) => ({ - id: 'test-user-id', - email: 'test@example.com', - emailVerified: true, - phoneVerified: false, - primaryAuthProvider: 'email' as const, - totpEnabled: false, - role: 'investor' as const, - status: 'active' as const, - failedLoginAttempts: 0, - createdAt: new Date(), - updatedAt: new Date(), - ...overrides, - }), - - /** - * Create a mock profile object - */ - createMockProfile: (overrides = {}) => ({ - id: 'test-profile-id', - userId: 'test-user-id', - firstName: 'John', - lastName: 'Doe', - displayName: 'John Doe', - timezone: 'UTC', - language: 'en', - preferredCurrency: 'USD', - ...overrides, - }), - - /** - * Create a mock session object - */ - createMockSession: (overrides = {}) => ({ - id: 'test-session-id', - userId: 'test-user-id', - refreshToken: 'mock-refresh-token', - userAgent: 'Mozilla/5.0', - ipAddress: '127.0.0.1', - expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), - createdAt: new Date(), - lastActiveAt: new Date(), - ...overrides, - }), -}; - -// Declare global TypeScript types -declare global { - // eslint-disable-next-line no-var - var testUtils: { - generateTestEmail: () => string; - generateTestPassword: () => string; - createMockUser: (overrides?: Record) => Record; - createMockProfile: (overrides?: Record) => Record; - createMockSession: (overrides?: Record) => Record; - }; -} - -// Clean up after all tests -afterAll(() => { - jest.clearAllMocks(); -}); diff --git a/apps/backend/src/config/index.ts b/apps/backend/src/config/index.ts deleted file mode 100644 index 76bde0b..0000000 --- a/apps/backend/src/config/index.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * Application Configuration - */ - -import dotenv from 'dotenv'; - -dotenv.config(); - -export const config = { - app: { - name: 'OrbiQuant IA', - version: '0.1.0', - env: process.env.NODE_ENV || 'development', - port: parseInt(process.env.PORT || '3000', 10), - frontendUrl: process.env.FRONTEND_URL || 'http://localhost:5173', - apiUrl: process.env.API_URL || 'http://localhost:3000', - }, - - cors: { - origins: process.env.CORS_ORIGINS?.split(',') || ['http://localhost:5173'], - }, - - jwt: { - accessSecret: process.env.JWT_ACCESS_SECRET || 'your-access-secret-change-in-production', - refreshSecret: process.env.JWT_REFRESH_SECRET || 'your-refresh-secret-change-in-production', - accessExpiry: process.env.JWT_ACCESS_EXPIRES || '15m', - refreshExpiry: process.env.JWT_REFRESH_EXPIRES || '7d', - }, - - database: { - host: process.env.DB_HOST || 'localhost', - port: parseInt(process.env.DB_PORT || '5432', 10), - name: process.env.DB_NAME || 'orbiquant', - user: process.env.DB_USER || 'postgres', - password: process.env.DB_PASSWORD || 'postgres', - ssl: process.env.DB_SSL === 'true', - poolMax: parseInt(process.env.DB_POOL_MAX || '20', 10), - idleTimeout: parseInt(process.env.DB_IDLE_TIMEOUT || '30000', 10), - connectionTimeout: parseInt(process.env.DB_CONNECTION_TIMEOUT || '5000', 10), - }, - - redis: { - host: process.env.REDIS_HOST || 'localhost', - port: parseInt(process.env.REDIS_PORT || '6379', 10), - password: process.env.REDIS_PASSWORD, - }, - - stripe: { - secretKey: process.env.STRIPE_SECRET_KEY || '', - webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '', - publishableKey: process.env.STRIPE_PUBLISHABLE_KEY || '', - }, - - mlEngine: { - baseUrl: process.env.ML_ENGINE_URL || 'http://localhost:8001', - timeout: parseInt(process.env.ML_ENGINE_TIMEOUT || '5000', 10), - }, - - rateLimit: { - windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '60000', 10), - max: parseInt(process.env.RATE_LIMIT_MAX || '100', 10), - }, - - email: { - host: process.env.EMAIL_HOST || 'smtp.gmail.com', - port: parseInt(process.env.EMAIL_PORT || '587', 10), - secure: process.env.EMAIL_SECURE === 'true', - user: process.env.EMAIL_USER || '', - password: process.env.EMAIL_PASSWORD || '', - from: process.env.EMAIL_FROM || 'noreply@orbiquant.io', - }, - - twilio: { - accountSid: process.env.TWILIO_ACCOUNT_SID || '', - authToken: process.env.TWILIO_AUTH_TOKEN || '', - phoneNumber: process.env.TWILIO_PHONE_NUMBER || '', - whatsappNumber: process.env.TWILIO_WHATSAPP_NUMBER || '', - verifyServiceSid: process.env.TWILIO_VERIFY_SERVICE_SID || '', - useVerifyService: process.env.TWILIO_USE_VERIFY_SERVICE === 'true', - }, - - oauth: { - google: { - clientId: process.env.GOOGLE_CLIENT_ID || '', - clientSecret: process.env.GOOGLE_CLIENT_SECRET || '', - callbackUrl: process.env.GOOGLE_CALLBACK_URL || 'http://localhost:3000/api/auth/google/callback', - scope: ['openid', 'email', 'profile'], - }, - facebook: { - clientId: process.env.FACEBOOK_CLIENT_ID || '', - clientSecret: process.env.FACEBOOK_CLIENT_SECRET || '', - callbackUrl: process.env.FACEBOOK_CALLBACK_URL || 'http://localhost:3000/api/auth/facebook/callback', - scope: ['email', 'public_profile'], - }, - twitter: { - clientId: process.env.TWITTER_CLIENT_ID || '', - clientSecret: process.env.TWITTER_CLIENT_SECRET || '', - callbackUrl: process.env.TWITTER_CALLBACK_URL || 'http://localhost:3000/api/auth/twitter/callback', - scope: ['users.read', 'tweet.read', 'offline.access'], - }, - apple: { - clientId: process.env.APPLE_CLIENT_ID || '', - clientSecret: process.env.APPLE_CLIENT_SECRET || '', - teamId: process.env.APPLE_TEAM_ID || '', - keyId: process.env.APPLE_KEY_ID || '', - privateKey: process.env.APPLE_PRIVATE_KEY || '', - callbackUrl: process.env.APPLE_CALLBACK_URL || 'http://localhost:3000/api/auth/apple/callback', - scope: ['name', 'email'], - }, - github: { - clientId: process.env.GITHUB_CLIENT_ID || '', - clientSecret: process.env.GITHUB_CLIENT_SECRET || '', - callbackUrl: process.env.GITHUB_CALLBACK_URL || 'http://localhost:3000/api/auth/github/callback', - scope: ['read:user', 'user:email'], - }, - }, -}; - -export type Config = typeof config; diff --git a/apps/backend/src/config/swagger.config.ts b/apps/backend/src/config/swagger.config.ts deleted file mode 100644 index f06b9cb..0000000 --- a/apps/backend/src/config/swagger.config.ts +++ /dev/null @@ -1,175 +0,0 @@ -/** - * Swagger/OpenAPI Configuration for OrbiQuant IA Trading Platform - */ - -import swaggerJSDoc from 'swagger-jsdoc'; -import { Express } from 'express'; -import swaggerUi from 'swagger-ui-express'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -// Swagger definition -const swaggerDefinition = { - openapi: '3.0.0', - info: { - title: 'OrbiQuant IA - Trading Platform API', - version: '1.0.0', - description: ` - API para la plataforma OrbiQuant IA - Trading y análisis cuantitativo con ML e IA. - - ## Características principales - - Autenticación OAuth2 y JWT con 2FA - - Trading automatizado y análisis cuantitativo - - Integración con agentes ML/LLM (Python microservices) - - WebSocket para datos de mercado en tiempo real - - Sistema de pagos y suscripciones (Stripe) - - Gestión de portfolios y estrategias de inversión - - ## Autenticación - La mayoría de los endpoints requieren autenticación mediante Bearer Token (JWT). - Algunos endpoints administrativos requieren API Key. - `, - contact: { - name: 'OrbiQuant Support', - email: 'support@orbiquant.com', - url: 'https://orbiquant.com', - }, - license: { - name: 'Proprietary', - }, - }, - servers: [ - { - url: 'http://localhost:3000/api/v1', - description: 'Desarrollo local', - }, - { - url: 'https://api.orbiquant.com/api/v1', - description: 'Producción', - }, - ], - tags: [ - { name: 'Auth', description: 'Autenticación y autorización (JWT, OAuth2, 2FA)' }, - { name: 'Users', description: 'Gestión de usuarios y perfiles' }, - { name: 'Education', description: 'Contenido educativo y cursos de trading' }, - { name: 'Trading', description: 'Operaciones de trading y gestión de órdenes' }, - { name: 'Investment', description: 'Gestión de inversiones y análisis de riesgo' }, - { name: 'Payments', description: 'Pagos, suscripciones y facturación (Stripe)' }, - { name: 'Portfolio', description: 'Gestión de portfolios y activos' }, - { name: 'ML', description: 'Machine Learning Engine - Predicciones y análisis' }, - { name: 'LLM', description: 'Large Language Model Agent - Asistente IA' }, - { name: 'Agents', description: 'Trading Agents automatizados' }, - { name: 'Admin', description: 'Administración del sistema' }, - { name: 'Health', description: 'Health checks y monitoreo' }, - { name: 'WebSocket', description: 'WebSocket endpoints y estadísticas' }, - ], - components: { - securitySchemes: { - BearerAuth: { - type: 'http', - scheme: 'bearer', - bearerFormat: 'JWT', - description: 'Token JWT obtenido del endpoint de login', - }, - ApiKeyAuth: { - type: 'apiKey', - in: 'header', - name: 'X-API-Key', - description: 'API Key para autenticación de servicios externos', - }, - }, - schemas: { - Error: { - type: 'object', - properties: { - success: { - type: 'boolean', - example: false, - }, - error: { - type: 'string', - example: 'Error message', - }, - statusCode: { - type: 'number', - example: 400, - }, - }, - }, - SuccessResponse: { - type: 'object', - properties: { - success: { - type: 'boolean', - example: true, - }, - data: { - type: 'object', - }, - message: { - type: 'string', - }, - }, - }, - }, - }, - security: [ - { - BearerAuth: [], - }, - ], -}; - -// Options for swagger-jsdoc -const options: swaggerJSDoc.Options = { - definition: swaggerDefinition, - // Path to the API routes for JSDoc comments - apis: [ - path.join(__dirname, '../modules/**/*.routes.ts'), - path.join(__dirname, '../modules/**/*.routes.js'), - path.join(__dirname, '../docs/openapi.yaml'), - ], -}; - -// Initialize swagger-jsdoc -const swaggerSpec = swaggerJSDoc(options); - -/** - * Setup Swagger documentation for Express app - */ -export function setupSwagger(app: Express, prefix: string = '/api/v1') { - // Swagger UI options - const swaggerUiOptions = { - customCss: ` - .swagger-ui .topbar { display: none } - .swagger-ui .info { margin: 50px 0; } - .swagger-ui .info .title { font-size: 36px; } - `, - customSiteTitle: 'OrbiQuant IA - API Documentation', - swaggerOptions: { - persistAuthorization: true, - displayRequestDuration: true, - filter: true, - tagsSorter: 'alpha', - operationsSorter: 'alpha', - }, - }; - - // Serve Swagger UI - app.use(`${prefix}/docs`, swaggerUi.serve); - app.get(`${prefix}/docs`, swaggerUi.setup(swaggerSpec, swaggerUiOptions)); - - // Serve OpenAPI spec as JSON - app.get(`${prefix}/docs.json`, (req, res) => { - res.setHeader('Content-Type', 'application/json'); - res.send(swaggerSpec); - }); - - console.log(`📚 Swagger docs available at: http://localhost:${process.env.PORT || 3000}${prefix}/docs`); - console.log(`📄 OpenAPI spec JSON at: http://localhost:${process.env.PORT || 3000}${prefix}/docs.json`); -} - -export { swaggerSpec }; diff --git a/apps/backend/src/core/filters/http-exception.filter.ts b/apps/backend/src/core/filters/http-exception.filter.ts deleted file mode 100644 index fcd7cc1..0000000 --- a/apps/backend/src/core/filters/http-exception.filter.ts +++ /dev/null @@ -1,172 +0,0 @@ -/** - * HTTP Exception Filter - * Unified error handling for all API errors - */ - -import { Request, Response, NextFunction } from 'express'; -import { logger } from '../../shared/utils/logger'; -import { HTTP_STATUS } from '../../shared/constants'; -import type { ApiResponse, ApiError } from '../../shared/types'; - -// Custom error class -export class HttpException extends Error { - public readonly statusCode: number; - public readonly code: string; - public readonly field?: string; - public readonly details?: unknown; - - constructor( - statusCode: number, - message: string, - code?: string, - field?: string, - details?: unknown - ) { - super(message); - this.statusCode = statusCode; - this.code = code || this.getDefaultCode(statusCode); - this.field = field; - this.details = details; - this.name = 'HttpException'; - - // Maintains proper stack trace for where our error was thrown - Error.captureStackTrace(this, HttpException); - } - - private getDefaultCode(statusCode: number): string { - const codes: Record = { - [HTTP_STATUS.BAD_REQUEST]: 'BAD_REQUEST', - [HTTP_STATUS.UNAUTHORIZED]: 'UNAUTHORIZED', - [HTTP_STATUS.FORBIDDEN]: 'FORBIDDEN', - [HTTP_STATUS.NOT_FOUND]: 'NOT_FOUND', - [HTTP_STATUS.CONFLICT]: 'CONFLICT', - [HTTP_STATUS.UNPROCESSABLE_ENTITY]: 'VALIDATION_ERROR', - [HTTP_STATUS.TOO_MANY_REQUESTS]: 'RATE_LIMIT_EXCEEDED', - [HTTP_STATUS.INTERNAL_SERVER_ERROR]: 'INTERNAL_ERROR', - [HTTP_STATUS.SERVICE_UNAVAILABLE]: 'SERVICE_UNAVAILABLE', - }; - return codes[statusCode] || 'UNKNOWN_ERROR'; - } -} - -// Specific exception classes -export class BadRequestException extends HttpException { - constructor(message: string, field?: string, details?: unknown) { - super(HTTP_STATUS.BAD_REQUEST, message, 'BAD_REQUEST', field, details); - } -} - -export class UnauthorizedException extends HttpException { - constructor(message = 'Unauthorized', code = 'UNAUTHORIZED') { - super(HTTP_STATUS.UNAUTHORIZED, message, code); - } -} - -export class ForbiddenException extends HttpException { - constructor(message = 'Forbidden') { - super(HTTP_STATUS.FORBIDDEN, message, 'FORBIDDEN'); - } -} - -export class NotFoundException extends HttpException { - constructor(resource = 'Resource') { - super(HTTP_STATUS.NOT_FOUND, `${resource} not found`, 'NOT_FOUND'); - } -} - -export class ConflictException extends HttpException { - constructor(message: string, field?: string) { - super(HTTP_STATUS.CONFLICT, message, 'CONFLICT', field); - } -} - -export class ValidationException extends HttpException { - constructor(message: string, field?: string, details?: unknown) { - super(HTTP_STATUS.UNPROCESSABLE_ENTITY, message, 'VALIDATION_ERROR', field, details); - } -} - -export class TooManyRequestsException extends HttpException { - constructor(message = 'Too many requests', retryAfter?: number) { - super(HTTP_STATUS.TOO_MANY_REQUESTS, message, 'RATE_LIMIT_EXCEEDED', undefined, { retryAfter }); - } -} - -// Global exception filter middleware -export function globalExceptionFilter( - error: Error | HttpException, - req: Request, - res: Response, - _next: NextFunction -): void { - const traceId = req.headers['x-request-id'] as string || crypto.randomUUID(); - - let statusCode: number = HTTP_STATUS.INTERNAL_SERVER_ERROR; - let apiError: ApiError = { - code: 'INTERNAL_ERROR', - message: 'An unexpected error occurred', - }; - - if (error instanceof HttpException) { - statusCode = error.statusCode; - apiError = { - code: error.code, - message: error.message, - field: error.field, - details: error.details, - }; - } else if (error.name === 'ValidationError') { - // Handle Zod/validator errors - statusCode = HTTP_STATUS.UNPROCESSABLE_ENTITY; - apiError = { - code: 'VALIDATION_ERROR', - message: error.message, - details: (error as unknown as Record).errors, - }; - } else if (error.name === 'JsonWebTokenError') { - statusCode = HTTP_STATUS.UNAUTHORIZED; - apiError = { - code: 'INVALID_TOKEN', - message: 'Invalid or malformed token', - }; - } else if (error.name === 'TokenExpiredError') { - statusCode = HTTP_STATUS.UNAUTHORIZED; - apiError = { - code: 'TOKEN_EXPIRED', - message: 'Token has expired', - }; - } - - // Log error - const reqUser = (req as unknown as Record).user; - const userId = reqUser ? (reqUser as Record).id : undefined; - - const logData = { - traceId, - method: req.method, - path: req.path, - statusCode, - errorCode: apiError.code, - message: apiError.message, - userId, - ip: req.ip, - }; - - if (statusCode >= 500) { - logger.error('Server error', { ...logData, stack: error.stack }); - } else if (statusCode >= 400) { - logger.warn('Client error', logData); - } - - // Send response - const response: ApiResponse = { - success: false, - error: apiError, - meta: { - traceId, - timestamp: new Date().toISOString(), - }, - }; - - res.status(statusCode).json(response); -} diff --git a/apps/backend/src/core/filters/index.ts b/apps/backend/src/core/filters/index.ts deleted file mode 100644 index dfe63d2..0000000 --- a/apps/backend/src/core/filters/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Filters - Barrel Export - */ - -export * from './http-exception.filter'; diff --git a/apps/backend/src/core/guards/auth.guard.ts b/apps/backend/src/core/guards/auth.guard.ts deleted file mode 100644 index b22d71f..0000000 --- a/apps/backend/src/core/guards/auth.guard.ts +++ /dev/null @@ -1,237 +0,0 @@ -/** - * Authentication Guards - * Middleware for protecting routes - */ - -import { Request, Response, NextFunction } from 'express'; -import jwt from 'jsonwebtoken'; -import { config } from '../../config'; -import { db } from '../../shared/database'; -import { UnauthorizedException, ForbiddenException } from '../filters/http-exception.filter'; -import { logger } from '../../shared/utils/logger'; -import type { AuthenticatedUser, UserRole } from '../../modules/auth/types/auth.types'; -import { UserRoleEnum } from '../../modules/auth/types/auth.types'; - -// Authenticated request type - user is required and has all auth properties -export interface AuthenticatedRequest extends Request { - user: AuthenticatedUser; -} - -interface JwtPayload { - sub: string; - email: string; - role: string; - sessionId?: string; - iat: number; - exp: number; -} - -/** - * Require authentication - * Validates JWT token and attaches user to request - */ -export async function requireAuth( - req: Request, - res: Response, - next: NextFunction -): Promise { - try { - const authHeader = req.headers.authorization; - - if (!authHeader || !authHeader.startsWith('Bearer ')) { - throw new UnauthorizedException('No token provided', 'NO_TOKEN'); - } - - const token = authHeader.substring(7); - - // Verify token - const decoded = jwt.verify(token, config.jwt.accessSecret) as JwtPayload; - - // Check if user exists and is active - const userResult = await db.query<{ - id: string; - email: string; - role: string; - status: string; - }>( - 'SELECT id, email, role, status FROM users WHERE id = $1', - [decoded.sub] - ); - - if (userResult.rows.length === 0) { - throw new UnauthorizedException('User not found', 'USER_NOT_FOUND'); - } - - const user = userResult.rows[0]; - - if (user.status !== 'active') { - throw new UnauthorizedException('Account is not active', 'ACCOUNT_INACTIVE'); - } - - // Attach user to request (partial user info from guard, full info loaded by middleware) - (req as AuthenticatedRequest).user = { - id: user.id, - email: user.email, - role: user.role as UserRole, - } as AuthenticatedUser; - - next(); - } catch (error) { - if (error instanceof UnauthorizedException) { - next(error); - } else if ((error as Error).name === 'JsonWebTokenError') { - next(new UnauthorizedException('Invalid token', 'INVALID_TOKEN')); - } else if ((error as Error).name === 'TokenExpiredError') { - next(new UnauthorizedException('Token expired', 'TOKEN_EXPIRED')); - } else { - logger.error('Auth guard error', { error: (error as Error).message }); - next(new UnauthorizedException('Authentication failed', 'AUTH_FAILED')); - } - } -} - -/** - * Optional authentication - * Attaches user if token is valid, but doesn't require it - */ -export async function optionalAuth( - req: Request, - res: Response, - next: NextFunction -): Promise { - try { - const authHeader = req.headers.authorization; - - if (!authHeader || !authHeader.startsWith('Bearer ')) { - return next(); - } - - const token = authHeader.substring(7); - const decoded = jwt.verify(token, config.jwt.accessSecret) as JwtPayload; - - const userResult = await db.query<{ - id: string; - email: string; - role: string; - status: string; - }>( - 'SELECT id, email, role, status FROM users WHERE id = $1 AND status = $2', - [decoded.sub, 'active'] - ); - - if (userResult.rows.length > 0) { - const user = userResult.rows[0]; - (req as AuthenticatedRequest).user = { - id: user.id, - email: user.email, - role: user.role as UserRole, - } as AuthenticatedUser; - } - - next(); - } catch { - // Ignore errors for optional auth - next(); - } -} - -/** - * Require specific roles - * Must be used after requireAuth - */ -export function requireRoles(...roles: UserRoleEnum[]) { - return (req: Request, res: Response, next: NextFunction): void => { - const authReq = req as AuthenticatedRequest; - - if (!authReq.user) { - return next(new UnauthorizedException('Authentication required')); - } - - if (!roles.includes(authReq.user.role as UserRoleEnum)) { - return next(new ForbiddenException('Insufficient permissions')); - } - - next(); - }; -} - -/** - * Require admin role - */ -export const requireAdmin = requireRoles(UserRoleEnum.ADMIN, UserRoleEnum.SUPER_ADMIN); - -/** - * Require instructor role - */ -export const requireInstructor = requireRoles( - UserRoleEnum.INSTRUCTOR, - UserRoleEnum.ADMIN, - UserRoleEnum.SUPER_ADMIN -); - -/** - * Resource ownership guard - * Checks if user owns the resource or is admin - */ -export function requireOwnership( - resourceUserIdExtractor: (req: Request) => string | Promise -) { - return async (req: Request, res: Response, next: NextFunction): Promise => { - try { - const authReq = req as AuthenticatedRequest; - - if (!authReq.user) { - return next(new UnauthorizedException('Authentication required')); - } - - // Admins can access any resource - if ([UserRoleEnum.ADMIN, UserRoleEnum.SUPER_ADMIN].includes(authReq.user.role as UserRoleEnum)) { - return next(); - } - - const resourceUserId = await resourceUserIdExtractor(req); - - if (authReq.user.id !== resourceUserId) { - return next(new ForbiddenException('You do not have access to this resource')); - } - - next(); - } catch (error) { - next(error); - } - }; -} - -/** - * Rate limit by user - * Applies stricter rate limiting per user - */ -export function userRateLimit(maxRequests: number, windowMs: number) { - const requests = new Map(); - - return (req: Request, res: Response, next: NextFunction): void => { - const authReq = req as AuthenticatedRequest; - const key = authReq.user?.id || req.ip || 'anonymous'; - const now = Date.now(); - - let userData = requests.get(key); - - if (!userData || now > userData.resetAt) { - userData = { count: 0, resetAt: now + windowMs }; - requests.set(key, userData); - } - - userData.count++; - - if (userData.count > maxRequests) { - res.set('Retry-After', String(Math.ceil((userData.resetAt - now) / 1000))); - return next( - new ForbiddenException( - `Rate limit exceeded. Try again in ${Math.ceil((userData.resetAt - now) / 1000)} seconds` - ) - ); - } - - next(); - }; -} diff --git a/apps/backend/src/core/guards/index.ts b/apps/backend/src/core/guards/index.ts deleted file mode 100644 index f15a19f..0000000 --- a/apps/backend/src/core/guards/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Guards - Barrel Export - */ - -export * from './auth.guard'; diff --git a/apps/backend/src/core/interceptors/index.ts b/apps/backend/src/core/interceptors/index.ts deleted file mode 100644 index f57d48f..0000000 --- a/apps/backend/src/core/interceptors/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Interceptors - Barrel Export - */ - -export * from './transform-response.interceptor'; diff --git a/apps/backend/src/core/interceptors/transform-response.interceptor.ts b/apps/backend/src/core/interceptors/transform-response.interceptor.ts deleted file mode 100644 index 7ec0307..0000000 --- a/apps/backend/src/core/interceptors/transform-response.interceptor.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Transform Response Interceptor - * Wraps all successful responses in a standard format - */ - -import { Request, Response, NextFunction } from 'express'; -import type { ApiResponse } from '../../shared/types'; - -/** - * Middleware that wraps successful responses in ApiResponse format - * Usage: app.use(transformResponse); - */ -export function transformResponse(req: Request, res: Response, next: NextFunction): void { - // Store original json method - const originalJson = res.json.bind(res); - - // Override json method - res.json = function (data: unknown): Response { - // Don't transform if already in ApiResponse format - if (data && typeof data === 'object' && 'success' in data) { - return originalJson(data); - } - - // Don't transform error responses (handled by exception filter) - if (res.statusCode >= 400) { - return originalJson(data); - } - - // Wrap successful response - const response: ApiResponse = { - success: true, - data, - meta: { - timestamp: new Date().toISOString(), - requestId: req.headers['x-request-id'] as string || undefined, - }, - }; - - return originalJson(response); - }; - - next(); -} - -/** - * Async handler wrapper - * Catches async errors and passes them to error handler - */ -export function asyncHandler( - fn: (req: Request, res: Response, next: NextFunction) => Promise -) { - return (req: Request, res: Response, next: NextFunction): void => { - Promise.resolve(fn(req, res, next)).catch(next); - }; -} - -/** - * Response helper methods - */ -export const responseHelpers = { - /** - * Send success response with data - */ - success(res: Response, data: T, statusCode = 200): Response { - return res.status(statusCode).json(data); - }, - - /** - * Send created response - */ - created(res: Response, data: T): Response { - return res.status(201).json(data); - }, - - /** - * Send no content response - */ - noContent(res: Response): Response { - return res.status(204).send(); - }, - - /** - * Send paginated response - */ - paginated( - res: Response, - data: T[], - pagination: { - page: number; - perPage: number; - total: number; - } - ): Response { - const totalPages = Math.ceil(pagination.total / pagination.perPage); - - return res.json({ - data, - pagination: { - page: pagination.page, - perPage: pagination.perPage, - total: pagination.total, - totalPages, - hasNext: pagination.page < totalPages, - hasPrev: pagination.page > 1, - }, - }); - }, -}; diff --git a/apps/backend/src/core/middleware/auth.middleware.ts b/apps/backend/src/core/middleware/auth.middleware.ts deleted file mode 100644 index ef9e1dc..0000000 --- a/apps/backend/src/core/middleware/auth.middleware.ts +++ /dev/null @@ -1,206 +0,0 @@ -// ============================================================================ -// OrbiQuant IA - Authentication Middleware -// ============================================================================ - -import { Request, Response, NextFunction } from 'express'; -import { tokenService } from '../../modules/auth/services/token.service'; -import { db } from '../../shared/database'; -import type { User, Profile, AuthenticatedUser } from '../../modules/auth/types/auth.types'; - -// Extend Express Request type -declare global { - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace Express { - interface Request { - user?: AuthenticatedUser; - sessionId?: string; - } - } -} - -export const authenticate = async ( - req: Request, - res: Response, - next: NextFunction -) => { - try { - const authHeader = req.headers.authorization; - - if (!authHeader || !authHeader.startsWith('Bearer ')) { - return res.status(401).json({ - success: false, - error: 'No authentication token provided', - }); - } - - const token = authHeader.substring(7); - const decoded = tokenService.verifyAccessToken(token); - - if (!decoded) { - return res.status(401).json({ - success: false, - error: 'Invalid or expired token', - }); - } - - // Get user from database - const userResult = await db.query( - `SELECT id, email, email_verified, phone, phone_verified, primary_auth_provider, - totp_enabled, role, status, last_login_at, created_at, updated_at - FROM users WHERE id = $1`, - [decoded.sub] - ); - - if (userResult.rows.length === 0) { - return res.status(401).json({ - success: false, - error: 'User not found', - }); - } - - const user = userResult.rows[0]; - - // Check user status - if (user.status === 'banned' || user.status === 'suspended') { - return res.status(403).json({ - success: false, - error: 'Account has been suspended', - }); - } - - // Get profile - const profileResult = await db.query( - 'SELECT * FROM profiles WHERE user_id = $1', - [user.id] - ); - - // Attach user to request - req.user = { - ...user, - profile: profileResult.rows[0], - }; - - next(); - } catch { - return res.status(401).json({ - success: false, - error: 'Authentication failed', - }); - } -}; - -export const optionalAuth = async ( - req: Request, - res: Response, - next: NextFunction -) => { - try { - const authHeader = req.headers.authorization; - - if (!authHeader || !authHeader.startsWith('Bearer ')) { - return next(); - } - - const token = authHeader.substring(7); - const decoded = tokenService.verifyAccessToken(token); - - if (!decoded) { - return next(); - } - - const userResult = await db.query( - `SELECT id, email, email_verified, phone, phone_verified, primary_auth_provider, - totp_enabled, role, status, last_login_at, created_at, updated_at - FROM users WHERE id = $1`, - [decoded.sub] - ); - - if (userResult.rows.length > 0) { - const profileResult = await db.query( - 'SELECT * FROM profiles WHERE user_id = $1', - [userResult.rows[0].id] - ); - - req.user = { - ...userResult.rows[0], - profile: profileResult.rows[0], - }; - } - - next(); - } catch { - next(); - } -}; - -export const requireRole = (...roles: string[]) => { - return (req: Request, res: Response, next: NextFunction) => { - if (!req.user) { - return res.status(401).json({ - success: false, - error: 'Authentication required', - }); - } - - if (!roles.includes(req.user.role)) { - return res.status(403).json({ - success: false, - error: 'Insufficient permissions', - }); - } - - next(); - }; -}; - -export const requireVerifiedEmail = ( - req: Request, - res: Response, - next: NextFunction -) => { - if (!req.user) { - return res.status(401).json({ - success: false, - error: 'Authentication required', - }); - } - - if (!req.user.emailVerified) { - return res.status(403).json({ - success: false, - error: 'Email verification required', - }); - } - - next(); -}; - -export const requireKYC = (level: number = 1) => { - return async (req: Request, res: Response, next: NextFunction) => { - if (!req.user) { - return res.status(401).json({ - success: false, - error: 'Authentication required', - }); - } - - const kycResult = await db.query( - `SELECT level FROM kyc_verifications - WHERE user_id = $1 AND status = 'approved' - ORDER BY level DESC LIMIT 1`, - [req.user.id] - ); - - const currentLevel = kycResult.rows[0]?.level || 0; - - if (currentLevel < level) { - return res.status(403).json({ - success: false, - error: `KYC level ${level} required. Current level: ${currentLevel}`, - data: { requiredLevel: level, currentLevel }, - }); - } - - next(); - }; -}; diff --git a/apps/backend/src/core/middleware/error-handler.ts b/apps/backend/src/core/middleware/error-handler.ts deleted file mode 100644 index 0884b15..0000000 --- a/apps/backend/src/core/middleware/error-handler.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Global Error Handler Middleware - */ - -import { Request, Response, NextFunction } from 'express'; -import { logger } from '../../shared/utils/logger.js'; - -export interface AppError extends Error { - statusCode?: number; - isOperational?: boolean; - code?: string; -} - -export const errorHandler = ( - err: AppError, - req: Request, - res: Response, - _next: NextFunction -): void => { - const statusCode = err.statusCode || 500; - const message = err.message || 'Internal Server Error'; - - // Log error - logger.error(`[${req.method}] ${req.path} - ${statusCode}: ${message}`, { - error: err, - stack: err.stack, - body: req.body, - params: req.params, - query: req.query, - }); - - // Send response - res.status(statusCode).json({ - success: false, - error: { - message, - code: err.code, - ...(process.env.NODE_ENV === 'development' && { stack: err.stack }), - }, - timestamp: new Date().toISOString(), - }); -}; - -// Custom error class -export class HttpError extends Error implements AppError { - statusCode: number; - isOperational: boolean; - code?: string; - - constructor(message: string, statusCode: number, code?: string) { - super(message); - this.statusCode = statusCode; - this.isOperational = true; - this.code = code; - - Error.captureStackTrace(this, this.constructor); - } -} - -// Common errors -export const BadRequestError = (message = 'Bad Request', code?: string) => - new HttpError(message, 400, code); - -export const UnauthorizedError = (message = 'Unauthorized', code?: string) => - new HttpError(message, 401, code); - -export const ForbiddenError = (message = 'Forbidden', code?: string) => - new HttpError(message, 403, code); - -export const NotFoundError = (message = 'Not Found', code?: string) => - new HttpError(message, 404, code); - -export const ConflictError = (message = 'Conflict', code?: string) => - new HttpError(message, 409, code); - -export const InternalError = (message = 'Internal Server Error', code?: string) => - new HttpError(message, 500, code); diff --git a/apps/backend/src/core/middleware/not-found.ts b/apps/backend/src/core/middleware/not-found.ts deleted file mode 100644 index 73cf401..0000000 --- a/apps/backend/src/core/middleware/not-found.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * 404 Not Found Handler - */ - -import { Request, Response } from 'express'; - -export const notFoundHandler = (req: Request, res: Response): void => { - res.status(404).json({ - success: false, - error: { - message: `Route ${req.method} ${req.path} not found`, - code: 'ROUTE_NOT_FOUND', - }, - timestamp: new Date().toISOString(), - }); -}; diff --git a/apps/backend/src/core/middleware/rate-limiter.ts b/apps/backend/src/core/middleware/rate-limiter.ts deleted file mode 100644 index e409f49..0000000 --- a/apps/backend/src/core/middleware/rate-limiter.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * Rate Limiter Middleware - */ - -import rateLimit from 'express-rate-limit'; -import { config } from '../../config'; - -export const rateLimiter = rateLimit({ - windowMs: config.rateLimit.windowMs, - max: config.rateLimit.max, - message: { - success: false, - error: { - message: 'Too many requests, please try again later', - code: 'RATE_LIMIT_EXCEEDED', - }, - }, - standardHeaders: true, - legacyHeaders: false, -}); - -// Standard rate limiter for auth endpoints -export const authRateLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: 30, // 30 requests per window - message: { - success: false, - error: { - message: 'Too many authentication attempts, please try again later', - code: 'AUTH_RATE_LIMIT_EXCEEDED', - }, - }, - standardHeaders: true, - legacyHeaders: false, -}); - -// Strict rate limiter for sensitive operations (login, register, OTP) -export const strictRateLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: 5, // 5 attempts per window - message: { - success: false, - error: { - message: 'Too many attempts, please try again later', - code: 'STRICT_RATE_LIMIT_EXCEEDED', - }, - }, - standardHeaders: true, - legacyHeaders: false, - skipSuccessfulRequests: true, // Don't count successful requests -}); diff --git a/apps/backend/src/core/websocket/index.ts b/apps/backend/src/core/websocket/index.ts deleted file mode 100644 index 0cbb24a..0000000 --- a/apps/backend/src/core/websocket/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * WebSocket Module Exports - */ - -export { wsManager } from './websocket.server'; -export type { WSClient, WSMessage, MessageHandler } from './websocket.server'; -export { tradingStreamService } from './trading-stream.service'; -export type { QuoteData, TradeData, SignalData, DepthData, KlineData } from './trading-stream.service'; diff --git a/apps/backend/src/core/websocket/trading-stream.service.ts b/apps/backend/src/core/websocket/trading-stream.service.ts deleted file mode 100644 index e2497fc..0000000 --- a/apps/backend/src/core/websocket/trading-stream.service.ts +++ /dev/null @@ -1,825 +0,0 @@ -/** - * Trading Stream Service - * Real-time market data streaming via WebSocket - * Now with direct Binance WebSocket integration for true real-time updates - */ - -import { wsManager, WSClient, WSMessage } from './websocket.server'; -import { mlIntegrationService } from '../../modules/ml/services/ml-integration.service'; -import { mlOverlayService } from '../../modules/ml/services/ml-overlay.service'; -import { binanceService, Kline } from '../../modules/trading/services/binance.service'; -import { logger } from '../../shared/utils/logger'; -import { EventEmitter } from 'events'; - -// ============================================================================ -// Types -// ============================================================================ - -export interface QuoteData { - symbol: string; - price: number; - bid: number; - ask: number; - volume: number; - change: number; - changePercent: number; - high: number; - low: number; - open: number; - previousClose: number; - timestamp: Date; -} - -export interface TradeData { - symbol: string; - price: number; - quantity: number; - side: 'buy' | 'sell'; - timestamp: Date; -} - -export interface DepthData { - symbol: string; - bids: [number, number][]; // [price, quantity] - asks: [number, number][]; - timestamp: Date; -} - -export interface SignalData { - symbol: string; - signalType: 'buy' | 'sell' | 'hold'; - confidence: number; - amdPhase: string; - targetPrice: number; - stopLoss: number; - timestamp: Date; -} - -export interface KlineData { - symbol: string; - interval: string; - time: number; - open: number; - high: number; - low: number; - close: number; - volume: number; - isFinal: boolean; - timestamp: Date; -} - -// Channel prefixes -const CHANNELS = { - QUOTES: 'quotes', - PRICE: 'price', - KLINES: 'klines', - TICKER: 'ticker', - TRADES: 'trades', - DEPTH: 'depth', - SIGNALS: 'signals', - OVERLAYS: 'overlays', - PORTFOLIO: 'portfolio', - ORDERS: 'orders', - ALERTS: 'alerts', -} as const; - -// ============================================================================ -// Trading Stream Service -// ============================================================================ - -class TradingStreamService extends EventEmitter { - private quoteIntervals: Map = new Map(); - private signalIntervals: Map = new Map(); - private binanceStreamRefs: Map = new Map(); - private priceCache: Map = new Map(); - private initialized: boolean = false; - - private readonly QUOTE_UPDATE_INTERVAL = 1000; // 1 second (fallback only) - private readonly SIGNAL_UPDATE_INTERVAL = 30000; // 30 seconds - private readonly MAX_SYMBOLS_PER_CLIENT = 50; - private readonly PRICE_CACHE_TTL = 5000; // 5 seconds - - /** - * Initialize streaming service - */ - initialize(): void { - if (this.initialized) return; - - // Register message handlers - wsManager.registerHandler('requestQuote', this.handleQuoteRequest.bind(this)); - wsManager.registerHandler('requestSignal', this.handleSignalRequest.bind(this)); - wsManager.registerHandler('requestOverlay', this.handleOverlayRequest.bind(this)); - - // Listen for subscription events - wsManager.on('subscribe', this.handleSubscribe.bind(this)); - wsManager.on('unsubscribe', this.handleUnsubscribe.bind(this)); - wsManager.on('disconnect', this.handleDisconnect.bind(this)); - - // Setup Binance WebSocket event listeners - this.setupBinanceListeners(); - - this.initialized = true; - logger.info('[TradingStream] Service initialized with Binance WebSocket integration'); - } - - /** - * Setup Binance WebSocket event listeners - */ - private setupBinanceListeners(): void { - // Listen for ticker updates (24h statistics) - binanceService.on('ticker', (data: Record) => { - const quote = this.transformTickerToQuote(data); - this.priceCache.set(quote.symbol, quote); - - // Broadcast to subscribed clients - wsManager.broadcast(`${CHANNELS.TICKER}:${quote.symbol}`, { - type: 'ticker', - data: quote, - }); - - // Also broadcast on price and quotes channels - wsManager.broadcast(`${CHANNELS.PRICE}:${quote.symbol}`, { - type: 'price', - data: { - symbol: quote.symbol, - price: quote.price, - change24h: quote.change, - changePercent24h: quote.changePercent, - high24h: quote.high, - low24h: quote.low, - volume24h: quote.volume, - timestamp: quote.timestamp.getTime(), - }, - }); - - wsManager.broadcast(`${CHANNELS.QUOTES}:${quote.symbol}`, { - type: 'quote', - data: quote, - }); - }); - - // Listen for kline updates (candlestick data) - binanceService.on('kline', (data: { symbol: string; interval: string; kline: Kline; isFinal: boolean }) => { - const klineData: KlineData = { - symbol: data.symbol, - interval: data.interval, - time: data.kline.openTime, - open: parseFloat(data.kline.open), - high: parseFloat(data.kline.high), - low: parseFloat(data.kline.low), - close: parseFloat(data.kline.close), - volume: parseFloat(data.kline.volume), - isFinal: data.isFinal, - timestamp: new Date(), - }; - - // Broadcast to subscribed clients - wsManager.broadcast(`${CHANNELS.KLINES}:${data.symbol}:${data.interval}`, { - type: 'kline', - data: klineData, - }); - }); - - // Listen for trade updates - binanceService.on('trade', (data: Record) => { - const tradeData: TradeData = { - symbol: data.symbol as string, - price: parseFloat(data.price as string), - quantity: parseFloat(data.quantity as string), - side: data.isBuyerMaker ? 'sell' : 'buy', - timestamp: new Date(data.time as number), - }; - - wsManager.broadcast(`${CHANNELS.TRADES}:${data.symbol as string}`, { - type: 'trade', - data: tradeData, - }); - }); - - // Listen for depth updates - binanceService.on('depth', (data: Record) => { - const depthData: DepthData = { - symbol: data.symbol as string, - bids: (data.bids as [string, string][]).map((b: [string, string]) => [parseFloat(b[0]), parseFloat(b[1])]), - asks: (data.asks as [string, string][]).map((a: [string, string]) => [parseFloat(a[0]), parseFloat(a[1])]), - timestamp: new Date(), - }; - - wsManager.broadcast(`${CHANNELS.DEPTH}:${data.symbol as string}`, { - type: 'depth', - data: depthData, - }); - }); - - logger.info('[TradingStream] Binance WebSocket listeners configured'); - } - - /** - * Transform Binance ticker to QuoteData - */ - private transformTickerToQuote(ticker: Record): QuoteData { - const price = parseFloat((ticker.c || ticker.lastPrice || '0') as string); - const change = parseFloat((ticker.p || ticker.priceChange || '0') as string); - const changePercent = parseFloat((ticker.P || ticker.priceChangePercent || '0') as string); - - return { - symbol: (ticker.s || ticker.symbol) as string, - price, - bid: parseFloat((ticker.b || ticker.bidPrice || '0') as string), - ask: parseFloat((ticker.a || ticker.askPrice || '0') as string), - volume: parseFloat((ticker.v || ticker.volume || '0') as string), - change, - changePercent, - high: parseFloat((ticker.h || ticker.highPrice || '0') as string), - low: parseFloat((ticker.l || ticker.lowPrice || '0') as string), - open: parseFloat((ticker.o || ticker.openPrice || '0') as string), - previousClose: parseFloat((ticker.x || ticker.prevClosePrice || '0') as string), - timestamp: new Date(), - }; - } - - /** - * Handle subscription to a channel - */ - private handleSubscribe(client: WSClient, channel: string): void { - const parts = channel.split(':'); - const type = parts[0]; - const symbol = parts[1]?.toUpperCase(); - const interval = parts[2]; // For klines - - if (!symbol) return; - - // Handle different channel types - if (type === CHANNELS.PRICE || type === CHANNELS.TICKER || type === CHANNELS.QUOTES) { - this.startTickerStream(symbol); - } else if (type === CHANNELS.KLINES && interval) { - this.startKlineStream(symbol, interval as '1m' | '3m' | '5m' | '15m' | '30m' | '1h' | '2h' | '4h' | '6h' | '8h' | '12h' | '1d' | '3d' | '1w' | '1M'); - } else if (type === CHANNELS.TRADES) { - this.startTradeStream(symbol); - } else if (type === CHANNELS.DEPTH) { - this.startDepthStream(symbol); - } else if (type === CHANNELS.SIGNALS) { - this.startSignalStream(symbol); - } - } - - /** - * Handle unsubscription from a channel - */ - private handleUnsubscribe(_client: WSClient, channel: string): void { - const parts = channel.split(':'); - const type = parts[0]; - const symbol = parts[1]?.toUpperCase(); - const interval = parts[2]; - - // Check if anyone is still subscribed to this channel - if (wsManager.getChannelSubscriberCount(channel) === 0) { - if (type === CHANNELS.PRICE || type === CHANNELS.TICKER || type === CHANNELS.QUOTES) { - this.stopTickerStream(symbol); - } else if (type === CHANNELS.KLINES && interval) { - this.stopKlineStream(symbol, interval as '1m' | '3m' | '5m' | '15m' | '30m' | '1h' | '2h' | '4h' | '6h' | '8h' | '12h' | '1d' | '3d' | '1w' | '1M'); - } else if (type === CHANNELS.TRADES) { - this.stopTradeStream(symbol); - } else if (type === CHANNELS.DEPTH) { - this.stopDepthStream(symbol); - } else if (type === CHANNELS.SIGNALS) { - this.stopSignalStream(symbol); - } - } - } - - /** - * Handle client disconnect - */ - private handleDisconnect(_client: WSClient): void { - // Clean up empty channels - wsManager.getActiveChannels().forEach((channel) => { - if (wsManager.getChannelSubscriberCount(channel) === 0) { - const parts = channel.split(':'); - const type = parts[0]; - const symbol = parts[1]; - const interval = parts[2]; - - if (type === CHANNELS.PRICE || type === CHANNELS.TICKER || type === CHANNELS.QUOTES) { - this.stopTickerStream(symbol); - } else if (type === CHANNELS.KLINES && interval) { - this.stopKlineStream(symbol, interval as '1m' | '3m' | '5m' | '15m' | '30m' | '1h' | '2h' | '4h' | '6h' | '8h' | '12h' | '1d' | '3d' | '1w' | '1M'); - } else if (type === CHANNELS.TRADES) { - this.stopTradeStream(symbol); - } else if (type === CHANNELS.DEPTH) { - this.stopDepthStream(symbol); - } else if (type === CHANNELS.SIGNALS) { - this.stopSignalStream(symbol); - } - } - }); - } - - /** - * Handle quote request message - */ - private async handleQuoteRequest(client: WSClient, message: WSMessage): Promise { - const { symbol } = message.data as { symbol: string }; - if (!symbol) return; - - try { - const quote = await this.fetchQuote(symbol.toUpperCase()); - wsManager.send(client, { - type: 'quote', - channel: `${CHANNELS.QUOTES}:${symbol.toUpperCase()}`, - data: quote, - }); - } catch { - wsManager.send(client, { - type: 'error', - data: { message: `Failed to fetch quote for ${symbol}` }, - }); - } - } - - /** - * Handle signal request message - */ - private async handleSignalRequest(client: WSClient, message: WSMessage): Promise { - const { symbol } = message.data as { symbol: string }; - if (!symbol) return; - - try { - const signal = await mlIntegrationService.getSignal(symbol.toUpperCase()); - wsManager.send(client, { - type: 'signal', - channel: `${CHANNELS.SIGNALS}:${symbol.toUpperCase()}`, - data: this.transformSignal(signal), - }); - } catch { - wsManager.send(client, { - type: 'error', - data: { message: `Failed to fetch signal for ${symbol}` }, - }); - } - } - - /** - * Handle overlay request message - */ - private async handleOverlayRequest(client: WSClient, message: WSMessage): Promise { - const { symbol, config } = message.data as { symbol: string; config?: Record }; - if (!symbol) return; - - try { - const overlay = await mlOverlayService.getChartOverlay(symbol.toUpperCase(), config); - wsManager.send(client, { - type: 'overlay', - channel: `${CHANNELS.OVERLAYS}:${symbol.toUpperCase()}`, - data: overlay, - }); - } catch { - wsManager.send(client, { - type: 'error', - data: { message: `Failed to fetch overlay for ${symbol}` }, - }); - } - } - - // ========================================================================== - // Binance WebSocket Streaming Methods - // ========================================================================== - - /** - * Start ticker stream (24h stats) via Binance WebSocket - */ - private startTickerStream(symbol: string): void { - const streamKey = `ticker:${symbol}`; - - // Check if already subscribed - if (this.binanceStreamRefs.has(streamKey)) { - logger.debug('[TradingStream] Ticker stream already active:', { symbol }); - return; - } - - try { - // Subscribe to Binance WebSocket ticker stream - binanceService.subscribeTicker(symbol); - this.binanceStreamRefs.set(streamKey, { type: 'ticker', symbol }); - logger.info('[TradingStream] Started Binance ticker stream:', { symbol }); - - // Send initial data from cache if available - const cached = this.priceCache.get(symbol); - if (cached) { - wsManager.broadcast(`${CHANNELS.TICKER}:${symbol}`, { - type: 'ticker', - data: cached, - }); - } - } catch (error) { - logger.error('[TradingStream] Failed to start ticker stream:', { symbol, error: (error as Error).message }); - } - } - - /** - * Stop ticker stream - */ - private stopTickerStream(symbol: string): void { - const streamKey = `ticker:${symbol}`; - - if (this.binanceStreamRefs.has(streamKey)) { - const streamName = `${symbol.toLowerCase()}@ticker`; - binanceService.unsubscribe(streamName); - this.binanceStreamRefs.delete(streamKey); - this.priceCache.delete(symbol); - logger.info('[TradingStream] Stopped Binance ticker stream:', { symbol }); - } - } - - /** - * Start kline/candlestick stream via Binance WebSocket - */ - private startKlineStream(symbol: string, interval: string): void { - const streamKey = `klines:${symbol}:${interval}`; - - if (this.binanceStreamRefs.has(streamKey)) { - logger.debug('[TradingStream] Kline stream already active:', { symbol, interval }); - return; - } - - try { - binanceService.subscribeKlines(symbol, interval as '1m' | '3m' | '5m' | '15m' | '30m' | '1h' | '2h' | '4h' | '6h' | '8h' | '12h' | '1d' | '3d' | '1w' | '1M'); - this.binanceStreamRefs.set(streamKey, { type: 'klines', symbol, interval }); - logger.info('[TradingStream] Started Binance kline stream:', { symbol, interval }); - } catch (error) { - logger.error('[TradingStream] Failed to start kline stream:', { symbol, interval, error: (error as Error).message }); - } - } - - /** - * Stop kline stream - */ - private stopKlineStream(symbol: string, interval: string): void { - const streamKey = `klines:${symbol}:${interval}`; - - if (this.binanceStreamRefs.has(streamKey)) { - const streamName = `${symbol.toLowerCase()}@kline_${interval}`; - binanceService.unsubscribe(streamName); - this.binanceStreamRefs.delete(streamKey); - logger.info('[TradingStream] Stopped Binance kline stream:', { symbol, interval }); - } - } - - /** - * Start trade stream via Binance WebSocket - */ - private startTradeStream(symbol: string): void { - const streamKey = `trades:${symbol}`; - - if (this.binanceStreamRefs.has(streamKey)) { - logger.debug('[TradingStream] Trade stream already active:', { symbol }); - return; - } - - try { - binanceService.subscribeTrades(symbol); - this.binanceStreamRefs.set(streamKey, { type: 'trades', symbol }); - logger.info('[TradingStream] Started Binance trade stream:', { symbol }); - } catch (error) { - logger.error('[TradingStream] Failed to start trade stream:', { symbol, error: (error as Error).message }); - } - } - - /** - * Stop trade stream - */ - private stopTradeStream(symbol: string): void { - const streamKey = `trades:${symbol}`; - - if (this.binanceStreamRefs.has(streamKey)) { - const streamName = `${symbol.toLowerCase()}@trade`; - binanceService.unsubscribe(streamName); - this.binanceStreamRefs.delete(streamKey); - logger.info('[TradingStream] Stopped Binance trade stream:', { symbol }); - } - } - - /** - * Start depth/order book stream via Binance WebSocket - */ - private startDepthStream(symbol: string, levels: 5 | 10 | 20 = 10): void { - const streamKey = `depth:${symbol}`; - - if (this.binanceStreamRefs.has(streamKey)) { - logger.debug('[TradingStream] Depth stream already active:', { symbol }); - return; - } - - try { - binanceService.subscribeDepth(symbol, levels); - this.binanceStreamRefs.set(streamKey, { type: 'depth', symbol }); - logger.info('[TradingStream] Started Binance depth stream:', { symbol, levels }); - } catch (error) { - logger.error('[TradingStream] Failed to start depth stream:', { symbol, error: (error as Error).message }); - } - } - - /** - * Stop depth stream - */ - private stopDepthStream(symbol: string): void { - const streamKey = `depth:${symbol}`; - - if (this.binanceStreamRefs.has(streamKey)) { - const streamName = `${symbol.toLowerCase()}@depth10@100ms`; - binanceService.unsubscribe(streamName); - this.binanceStreamRefs.delete(streamKey); - logger.info('[TradingStream] Stopped Binance depth stream:', { symbol }); - } - } - - // ========================================================================== - // Legacy Quote Streaming (Fallback) - // ========================================================================== - - /** - * Start streaming quotes for a symbol (LEGACY - uses polling as fallback) - */ - private startQuoteStream(symbol: string): void { - const key = `quotes:${symbol}`; - if (this.quoteIntervals.has(key)) return; - - const interval = setInterval(async () => { - try { - const quote = await this.fetchQuote(symbol); - wsManager.broadcast(`${CHANNELS.QUOTES}:${symbol}`, { - type: 'quote', - data: quote, - }); - } catch (_error) { - logger.error('[TradingStream] Quote fetch error:', { symbol, error: (_error as Error).message }); - } - }, this.QUOTE_UPDATE_INTERVAL); - - this.quoteIntervals.set(key, interval); - logger.debug('[TradingStream] Started quote stream (polling fallback):', { symbol }); - } - - /** - * Stop streaming quotes for a symbol (LEGACY) - */ - private stopQuoteStream(symbol: string): void { - const key = `quotes:${symbol}`; - const interval = this.quoteIntervals.get(key); - if (interval) { - clearInterval(interval); - this.quoteIntervals.delete(key); - logger.debug('[TradingStream] Stopped quote stream:', { symbol }); - } - } - - /** - * Fetch quote data from Binance - */ - private async fetchQuote(symbol: string): Promise { - try { - // Get 24hr ticker from Binance - const result = await binanceService.get24hrTicker(symbol); - const ticker = Array.isArray(result) ? result[0] : result; - - if (!ticker) { - throw new Error('No ticker data'); - } - - const price = parseFloat(ticker.lastPrice); - const change = parseFloat(ticker.priceChange); - const changePercent = parseFloat(ticker.priceChangePercent); - - return { - symbol: ticker.symbol, - price, - bid: parseFloat(ticker.bidPrice), - ask: parseFloat(ticker.askPrice), - volume: parseFloat(ticker.volume), - change, - changePercent, - high: parseFloat(ticker.highPrice), - low: parseFloat(ticker.lowPrice), - open: parseFloat(ticker.openPrice), - previousClose: parseFloat(ticker.prevClosePrice), - timestamp: new Date(), - }; - } catch { - // Fallback to simulated data if Binance fails - logger.warn('[TradingStream] Binance fetch failed, using mock data:', { symbol }); - return this.getMockQuote(symbol); - } - } - - /** - * Get mock quote data (fallback) - */ - private getMockQuote(symbol: string): QuoteData { - const basePrice = this.getBasePrice(symbol); - const change = (Math.random() - 0.5) * basePrice * 0.02; - const price = basePrice + change; - - return { - symbol, - price: parseFloat(price.toFixed(2)), - bid: parseFloat((price - 0.01).toFixed(2)), - ask: parseFloat((price + 0.01).toFixed(2)), - volume: Math.floor(Math.random() * 1000000), - change: parseFloat(change.toFixed(2)), - changePercent: parseFloat(((change / basePrice) * 100).toFixed(2)), - high: parseFloat((price + Math.random() * 2).toFixed(2)), - low: parseFloat((price - Math.random() * 2).toFixed(2)), - open: parseFloat((price - change * 0.5).toFixed(2)), - previousClose: parseFloat(basePrice.toFixed(2)), - timestamp: new Date(), - }; - } - - /** - * Get base price for a symbol (mock fallback) - */ - private getBasePrice(symbol: string): number { - const prices: Record = { - BTCUSDT: 97500.00, - ETHUSDT: 3650.00, - BNBUSDT: 720.00, - SOLUSDT: 235.00, - XRPUSDT: 2.45, - DOGEUSDT: 0.42, - ADAUSDT: 1.10, - AVAXUSDT: 48.50, - DOTUSDT: 9.25, - MATICUSDT: 0.58, - }; - return prices[symbol.toUpperCase()] || 100 + Math.random() * 100; - } - - // ========================================================================== - // Signal Streaming - // ========================================================================== - - /** - * Start streaming signals for a symbol - */ - private startSignalStream(symbol: string): void { - const key = `signals:${symbol}`; - if (this.signalIntervals.has(key)) return; - - // Initial signal fetch - this.broadcastSignal(symbol); - - const interval = setInterval(async () => { - await this.broadcastSignal(symbol); - }, this.SIGNAL_UPDATE_INTERVAL); - - this.signalIntervals.set(key, interval); - logger.debug('[TradingStream] Started signal stream:', { symbol }); - } - - /** - * Stop streaming signals for a symbol - */ - private stopSignalStream(symbol: string): void { - const key = `signals:${symbol}`; - const interval = this.signalIntervals.get(key); - if (interval) { - clearInterval(interval); - this.signalIntervals.delete(key); - logger.debug('[TradingStream] Stopped signal stream:', { symbol }); - } - } - - /** - * Broadcast signal update - */ - private async broadcastSignal(symbol: string): Promise { - try { - const signal = await mlIntegrationService.getSignal(symbol); - wsManager.broadcast(`${CHANNELS.SIGNALS}:${symbol}`, { - type: 'signal', - data: this.transformSignal(signal), - }); - } catch (_error) { - logger.error('[TradingStream] Signal fetch error:', { symbol, error: (_error as Error).message }); - } - } - - /** - * Transform ML signal to stream format - */ - private transformSignal(signal: unknown): SignalData { - const s = signal as Record; - const prediction = s.prediction as Record | undefined; - return { - symbol: s.symbol as string, - signalType: s.signalType as 'buy' | 'sell' | 'hold', - confidence: s.confidence as number, - amdPhase: s.amdPhase as string, - targetPrice: (prediction?.targetPrice as number) || 0, - stopLoss: (prediction?.stopLoss as number) || 0, - timestamp: new Date(s.timestamp as string | number), - }; - } - - // ========================================================================== - // Public Methods - // ========================================================================== - - /** - * Broadcast trade execution to user - */ - broadcastTradeExecution(userId: string, trade: TradeData): void { - wsManager.sendToUser(userId, { - type: 'trade', - channel: `${CHANNELS.TRADES}:${trade.symbol}`, - data: trade, - }); - } - - /** - * Broadcast order update to user - */ - broadcastOrderUpdate(userId: string, order: unknown): void { - wsManager.sendToUser(userId, { - type: 'orderUpdate', - channel: CHANNELS.ORDERS, - data: order, - }); - } - - /** - * Broadcast portfolio update to user - */ - broadcastPortfolioUpdate(userId: string, portfolio: unknown): void { - wsManager.sendToUser(userId, { - type: 'portfolioUpdate', - channel: CHANNELS.PORTFOLIO, - data: portfolio, - }); - } - - /** - * Broadcast alert to user - */ - broadcastAlert(userId: string, alert: unknown): void { - wsManager.sendToUser(userId, { - type: 'alert', - channel: CHANNELS.ALERTS, - data: alert, - }); - } - - /** - * Broadcast system announcement to all - */ - broadcastAnnouncement(message: string): void { - wsManager.broadcastAll({ - type: 'announcement', - data: { message }, - }); - } - - /** - * Get streaming stats - */ - getStats(): { - connectedClients: number; - activeChannels: string[]; - quoteStreams: number; - signalStreams: number; - binanceStreams: number; - binanceActiveStreams: string[]; - priceCache: number; - } { - return { - connectedClients: wsManager.getClientCount(), - activeChannels: wsManager.getActiveChannels(), - quoteStreams: this.quoteIntervals.size, - signalStreams: this.signalIntervals.size, - binanceStreams: this.binanceStreamRefs.size, - binanceActiveStreams: binanceService.getActiveStreams(), - priceCache: this.priceCache.size, - }; - } - - /** - * Shutdown service - */ - shutdown(): void { - // Clear polling intervals - this.quoteIntervals.forEach((interval) => clearInterval(interval)); - this.signalIntervals.forEach((interval) => clearInterval(interval)); - this.quoteIntervals.clear(); - this.signalIntervals.clear(); - - // Unsubscribe from all Binance streams - binanceService.unsubscribeAll(); - this.binanceStreamRefs.clear(); - this.priceCache.clear(); - - logger.info('[TradingStream] Service shut down'); - } -} - -// Export singleton instance -export const tradingStreamService = new TradingStreamService(); diff --git a/apps/backend/src/core/websocket/websocket.server.ts b/apps/backend/src/core/websocket/websocket.server.ts deleted file mode 100644 index e869d79..0000000 --- a/apps/backend/src/core/websocket/websocket.server.ts +++ /dev/null @@ -1,418 +0,0 @@ -/** - * WebSocket Server - * Real-time streaming for trading data - */ - -import { WebSocketServer, WebSocket, RawData } from 'ws'; -import { Server as HttpServer } from 'http'; -import { parse as parseUrl } from 'url'; -import jwt from 'jsonwebtoken'; -import { config } from '../../config'; -import { logger } from '../../shared/utils/logger'; -import { EventEmitter } from 'events'; - -// ============================================================================ -// Types -// ============================================================================ - -export interface WSClient { - id: string; - ws: WebSocket; - userId?: string; - subscriptions: Set; - isAlive: boolean; - connectedAt: Date; - lastPing: Date; -} - -export interface WSMessage { - type: string; - channel?: string; - data?: unknown; - timestamp?: string; -} - -export interface SubscriptionMessage { - type: 'subscribe' | 'unsubscribe'; - channels: string[]; -} - -export type MessageHandler = (client: WSClient, message: WSMessage) => void | Promise; - -// ============================================================================ -// WebSocket Manager -// ============================================================================ - -class WebSocketManager extends EventEmitter { - private wss: WebSocketServer | null = null; - private clients: Map = new Map(); - private channelSubscribers: Map> = new Map(); - private messageHandlers: Map = new Map(); - private heartbeatInterval: NodeJS.Timeout | null = null; - private readonly HEARTBEAT_INTERVAL = 30000; - private readonly CLIENT_TIMEOUT = 60000; - - /** - * Initialize WebSocket server - */ - initialize(httpServer: HttpServer): void { - this.wss = new WebSocketServer({ - server: httpServer, - path: '/ws', - verifyClient: this.verifyClient.bind(this), - }); - - this.wss.on('connection', this.handleConnection.bind(this)); - this.wss.on('error', (error) => { - logger.error('[WS] Server error:', { error: error.message }); - }); - - this.startHeartbeat(); - logger.info('[WS] WebSocket server initialized'); - } - - /** - * Verify client connection (authentication) - */ - private verifyClient( - info: { origin: string; req: { url?: string } }, - callback: (result: boolean, code?: number, message?: string) => void - ): void { - try { - const url = parseUrl(info.req.url || '', true); - const token = url.query.token as string; - - // Allow connection without token (public channels only) - if (!token) { - callback(true); - return; - } - - // Verify JWT token - jwt.verify(token, config.jwt.accessSecret); - callback(true); - } catch (error) { - logger.warn('[WS] Client verification failed:', { error: (error as Error).message }); - callback(false, 401, 'Unauthorized'); - } - } - - /** - * Handle new WebSocket connection - */ - private handleConnection(ws: WebSocket, req: { url?: string }): void { - const clientId = this.generateClientId(); - const url = parseUrl(req.url || '', true); - const token = url.query.token as string; - - let userId: string | undefined; - if (token) { - try { - const decoded = jwt.verify(token, config.jwt.accessSecret) as { sub: string }; - userId = decoded.sub; - } catch { - // Token invalid, continue as anonymous - } - } - - const client: WSClient = { - id: clientId, - ws, - userId, - subscriptions: new Set(), - isAlive: true, - connectedAt: new Date(), - lastPing: new Date(), - }; - - this.clients.set(clientId, client); - - // Setup event handlers - ws.on('message', (data) => this.handleMessage(client, data)); - ws.on('pong', () => { - client.isAlive = true; - client.lastPing = new Date(); - }); - ws.on('close', () => this.handleDisconnect(client)); - ws.on('error', (error) => { - logger.error('[WS] Client error:', { clientId, error: error.message }); - }); - - // Send welcome message - this.send(client, { - type: 'connected', - data: { - clientId, - authenticated: !!userId, - timestamp: new Date().toISOString(), - }, - }); - - this.emit('connection', client); - logger.info('[WS] Client connected:', { clientId, userId, authenticated: !!userId }); - } - - /** - * Handle incoming message - */ - private handleMessage(client: WSClient, rawData: RawData): void { - try { - const message = JSON.parse(rawData.toString()) as WSMessage; - - // Handle subscription messages - if (message.type === 'subscribe' || message.type === 'unsubscribe') { - this.handleSubscription(client, message as SubscriptionMessage); - return; - } - - // Handle ping - if (message.type === 'ping') { - this.send(client, { type: 'pong', timestamp: new Date().toISOString() }); - return; - } - - // Call registered message handler - const handler = this.messageHandlers.get(message.type); - if (handler) { - handler(client, message); - } else { - logger.debug('[WS] Unknown message type:', { type: message.type, clientId: client.id }); - } - - this.emit('message', client, message); - } catch (error) { - logger.error('[WS] Message parse error:', { error: (error as Error).message }); - this.send(client, { - type: 'error', - data: { message: 'Invalid message format' }, - }); - } - } - - /** - * Handle subscription requests - */ - private handleSubscription(client: WSClient, message: SubscriptionMessage): void { - const channels = Array.isArray(message.channels) ? message.channels : []; - - channels.forEach((channel) => { - // Check if channel requires authentication - if (this.isPrivateChannel(channel) && !client.userId) { - this.send(client, { - type: 'error', - channel, - data: { message: 'Authentication required for this channel' }, - }); - return; - } - - if (message.type === 'subscribe') { - // Subscribe - client.subscriptions.add(channel); - - if (!this.channelSubscribers.has(channel)) { - this.channelSubscribers.set(channel, new Set()); - } - this.channelSubscribers.get(channel)!.add(client.id); - - this.send(client, { - type: 'subscribed', - channel, - timestamp: new Date().toISOString(), - }); - - this.emit('subscribe', client, channel); - logger.debug('[WS] Client subscribed:', { clientId: client.id, channel }); - } else { - // Unsubscribe - client.subscriptions.delete(channel); - this.channelSubscribers.get(channel)?.delete(client.id); - - this.send(client, { - type: 'unsubscribed', - channel, - timestamp: new Date().toISOString(), - }); - - this.emit('unsubscribe', client, channel); - logger.debug('[WS] Client unsubscribed:', { clientId: client.id, channel }); - } - }); - } - - /** - * Handle client disconnect - */ - private handleDisconnect(client: WSClient): void { - // Remove from all channel subscriptions - client.subscriptions.forEach((channel) => { - this.channelSubscribers.get(channel)?.delete(client.id); - }); - - this.clients.delete(client.id); - this.emit('disconnect', client); - logger.info('[WS] Client disconnected:', { clientId: client.id, userId: client.userId }); - } - - /** - * Start heartbeat to detect dead connections - */ - private startHeartbeat(): void { - this.heartbeatInterval = setInterval(() => { - const now = Date.now(); - - this.clients.forEach((client) => { - if (!client.isAlive) { - // Client didn't respond to last ping - logger.warn('[WS] Client timed out:', { clientId: client.id }); - client.ws.terminate(); - return; - } - - // Check for stale connections - if (now - client.lastPing.getTime() > this.CLIENT_TIMEOUT) { - logger.warn('[WS] Client connection stale:', { clientId: client.id }); - client.ws.terminate(); - return; - } - - client.isAlive = false; - client.ws.ping(); - }); - }, this.HEARTBEAT_INTERVAL); - } - - /** - * Send message to a client - */ - send(client: WSClient, message: WSMessage): void { - if (client.ws.readyState === WebSocket.OPEN) { - client.ws.send(JSON.stringify({ - ...message, - timestamp: message.timestamp || new Date().toISOString(), - })); - } - } - - /** - * Broadcast to a channel - */ - broadcast(channel: string, message: WSMessage): void { - const subscribers = this.channelSubscribers.get(channel); - if (!subscribers) return; - - const payload = JSON.stringify({ - ...message, - channel, - timestamp: message.timestamp || new Date().toISOString(), - }); - - subscribers.forEach((clientId) => { - const client = this.clients.get(clientId); - if (client && client.ws.readyState === WebSocket.OPEN) { - client.ws.send(payload); - } - }); - } - - /** - * Broadcast to all clients - */ - broadcastAll(message: WSMessage): void { - const payload = JSON.stringify({ - ...message, - timestamp: message.timestamp || new Date().toISOString(), - }); - - this.clients.forEach((client) => { - if (client.ws.readyState === WebSocket.OPEN) { - client.ws.send(payload); - } - }); - } - - /** - * Send to specific user (all their connections) - */ - sendToUser(userId: string, message: WSMessage): void { - const payload = JSON.stringify({ - ...message, - timestamp: message.timestamp || new Date().toISOString(), - }); - - this.clients.forEach((client) => { - if (client.userId === userId && client.ws.readyState === WebSocket.OPEN) { - client.ws.send(payload); - } - }); - } - - /** - * Register message handler - */ - registerHandler(type: string, handler: MessageHandler): void { - this.messageHandlers.set(type, handler); - } - - /** - * Get channel subscriber count - */ - getChannelSubscriberCount(channel: string): number { - return this.channelSubscribers.get(channel)?.size || 0; - } - - /** - * Get connected clients count - */ - getClientCount(): number { - return this.clients.size; - } - - /** - * Get all subscribed channels - */ - getActiveChannels(): string[] { - return Array.from(this.channelSubscribers.keys()).filter( - (channel) => (this.channelSubscribers.get(channel)?.size || 0) > 0 - ); - } - - /** - * Check if channel is private (requires auth) - */ - private isPrivateChannel(channel: string): boolean { - return ( - channel.startsWith('user:') || - channel.startsWith('portfolio:') || - channel.startsWith('account:') || - channel.startsWith('orders:') - ); - } - - /** - * Generate unique client ID - */ - private generateClientId(): string { - return `ws_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - } - - /** - * Shutdown WebSocket server - */ - shutdown(): void { - if (this.heartbeatInterval) { - clearInterval(this.heartbeatInterval); - } - - this.clients.forEach((client) => { - this.send(client, { type: 'shutdown', data: { message: 'Server shutting down' } }); - client.ws.close(); - }); - - this.wss?.close(); - logger.info('[WS] WebSocket server shut down'); - } -} - -// Export singleton instance -export const wsManager = new WebSocketManager(); diff --git a/apps/backend/src/docs/openapi.yaml b/apps/backend/src/docs/openapi.yaml deleted file mode 100644 index 2acd9e6..0000000 --- a/apps/backend/src/docs/openapi.yaml +++ /dev/null @@ -1,172 +0,0 @@ -openapi: 3.0.0 -info: - title: OrbiQuant IA - Trading Platform API - description: | - API para la plataforma OrbiQuant IA - Trading y análisis cuantitativo con ML e IA. - - ## Características principales - - Autenticación OAuth2 y JWT - - Trading automatizado y análisis cuantitativo - - Integración con agentes ML/LLM - - WebSocket para datos en tiempo real - - Sistema de pagos y suscripciones - - Gestión de portfolios y estrategias - - ## Autenticación - La mayoría de los endpoints requieren autenticación mediante Bearer Token (JWT). - - version: 1.0.0 - contact: - name: OrbiQuant Support - email: support@orbiquant.com - url: https://orbiquant.com - license: - name: Proprietary - -servers: - - url: http://localhost:3000/api/v1 - description: Desarrollo local - - url: https://api.orbiquant.com/api/v1 - description: Producción - -tags: - - name: Auth - description: Autenticación y autorización - - name: Users - description: Gestión de usuarios y perfiles - - name: Education - description: Contenido educativo y cursos - - name: Trading - description: Operaciones de trading y órdenes - - name: Investment - description: Gestión de inversiones y análisis - - name: Payments - description: Pagos y suscripciones (Stripe) - - name: Portfolio - description: Gestión de portfolios y activos - - name: ML - description: Machine Learning Engine - - name: LLM - description: Large Language Model Agent - - name: Agents - description: Trading Agents automatizados - - name: Admin - description: Administración del sistema - -components: - securitySchemes: - BearerAuth: - type: http - scheme: bearer - bearerFormat: JWT - description: Token JWT obtenido del endpoint de login - - ApiKeyAuth: - type: apiKey - in: header - name: X-API-Key - description: API Key para autenticación de servicios - - schemas: - Error: - type: object - properties: - success: - type: boolean - example: false - error: - type: string - example: "Error message" - statusCode: - type: number - example: 400 - - SuccessResponse: - type: object - properties: - success: - type: boolean - example: true - data: - type: object - message: - type: string - -security: - - BearerAuth: [] - -paths: - /health: - get: - tags: - - Health - summary: Health check del servidor - security: [] - responses: - '200': - description: Servidor funcionando correctamente - content: - application/json: - schema: - type: object - properties: - status: - type: string - example: healthy - version: - type: string - example: 0.1.0 - timestamp: - type: string - format: date-time - environment: - type: string - example: development - - /health/services: - get: - tags: - - Health - summary: Health check de microservicios Python - security: [] - responses: - '200': - description: Estado de los microservicios - content: - application/json: - schema: - type: object - properties: - status: - type: string - example: healthy - services: - type: object - properties: - mlEngine: - type: object - properties: - status: - type: string - example: healthy - latency: - type: number - example: 45 - llmAgent: - type: object - properties: - status: - type: string - example: healthy - latency: - type: number - example: 120 - tradingAgents: - type: object - properties: - status: - type: string - example: healthy - latency: - type: number - example: 60 diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts deleted file mode 100644 index ca1419c..0000000 --- a/apps/backend/src/index.ts +++ /dev/null @@ -1,196 +0,0 @@ -/** - * OrbiQuant IA - Backend API - * ========================== - * - * Main entry point for the Express.js backend API. - */ - -import express, { Express, Request, Response } from 'express'; -import { createServer } from 'http'; -import cors from 'cors'; -import helmet from 'helmet'; -import compression from 'compression'; -import morgan from 'morgan'; -import { config } from './config/index.js'; -import { logger } from './shared/utils/logger.js'; -import { setupSwagger } from './config/swagger.config.js'; - -// WebSocket -import { wsManager, tradingStreamService } from './core/websocket/index.js'; - -// Import routes -import { authRouter } from './modules/auth/auth.routes.js'; -import { usersRouter } from './modules/users/users.routes.js'; -import { educationRouter } from './modules/education/education.routes.js'; -import { tradingRouter } from './modules/trading/trading.routes.js'; -import { investmentRouter } from './modules/investment/investment.routes.js'; -import { paymentsRouter } from './modules/payments/payments.routes.js'; -import { adminRouter } from './modules/admin/admin.routes.js'; -import { mlRouter } from './modules/ml/ml.routes.js'; -import { llmRouter } from './modules/llm/llm.routes.js'; -import { portfolioRouter } from './modules/portfolio/portfolio.routes.js'; -import { agentsRouter } from './modules/agents/agents.routes.js'; - -// Service clients for health checks -import { tradingAgentsClient, mlEngineClient, llmAgentClient } from './shared/clients/index.js'; - -// Import middleware -import { errorHandler } from './core/middleware/error-handler.js'; -import { notFoundHandler } from './core/middleware/not-found.js'; -import { rateLimiter } from './core/middleware/rate-limiter.js'; - -const app: Express = express(); - -// Trust proxy (for rate limiting behind reverse proxy) -app.set('trust proxy', 1); - -// Security middleware -app.use(helmet()); - -// CORS -app.use(cors({ - origin: config.cors.origins, - credentials: true, - methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'], -})); - -// Compression -app.use(compression()); - -// Request logging -app.use(morgan('combined', { - stream: { write: (message) => logger.info(message.trim()) } -})); - -// Body parsing -app.use(express.json({ limit: '10mb' })); -app.use(express.urlencoded({ extended: true, limit: '10mb' })); - -// Rate limiting -app.use(rateLimiter); - -// Swagger documentation -setupSwagger(app, '/api/v1'); - -// Health check (before auth) -app.get('/health', (req: Request, res: Response) => { - res.json({ - status: 'healthy', - version: config.app.version, - timestamp: new Date().toISOString(), - environment: config.app.env, - }); -}); - -// Services health check - checks all Python microservices -app.get('/health/services', async (req: Request, res: Response) => { - const services: Record = {}; - - // Check ML Engine - const mlStart = Date.now(); - try { - await mlEngineClient.healthCheck(); - services.mlEngine = { status: 'healthy', latency: Date.now() - mlStart }; - } catch (error) { - services.mlEngine = { status: 'unhealthy', error: (error as Error).message }; - } - - // Check LLM Agent - const llmStart = Date.now(); - try { - await llmAgentClient.healthCheck(); - services.llmAgent = { status: 'healthy', latency: Date.now() - llmStart }; - } catch (error) { - services.llmAgent = { status: 'unhealthy', error: (error as Error).message }; - } - - // Check Trading Agents - const agentsStart = Date.now(); - try { - await tradingAgentsClient.healthCheck(); - services.tradingAgents = { status: 'healthy', latency: Date.now() - agentsStart }; - } catch (error) { - services.tradingAgents = { status: 'unhealthy', error: (error as Error).message }; - } - - const allHealthy = Object.values(services).every(s => s.status === 'healthy'); - - res.json({ - status: allHealthy ? 'healthy' : 'degraded', - services, - timestamp: new Date().toISOString(), - }); -}); - -// API routes -const apiRouter = express.Router(); - -apiRouter.use('/auth', authRouter); -apiRouter.use('/users', usersRouter); -apiRouter.use('/education', educationRouter); -apiRouter.use('/trading', tradingRouter); -apiRouter.use('/investment', investmentRouter); -apiRouter.use('/payments', paymentsRouter); -apiRouter.use('/admin', adminRouter); -apiRouter.use('/ml', mlRouter); -apiRouter.use('/llm', llmRouter); -apiRouter.use('/portfolio', portfolioRouter); -apiRouter.use('/agents', agentsRouter); - -// Mount API router -app.use('/api/v1', apiRouter); - -// 404 handler -app.use(notFoundHandler); - -// Error handler -app.use(errorHandler); - -// Create HTTP server -const httpServer = createServer(app); - -// Initialize WebSocket -wsManager.initialize(httpServer); -tradingStreamService.initialize(); - -// WebSocket stats endpoint -app.get('/api/v1/ws/stats', (req: Request, res: Response) => { - res.json({ - success: true, - data: tradingStreamService.getStats(), - }); -}); - -// Start server -const PORT = config.app.port; - -httpServer.listen(PORT, () => { - logger.info(`🚀 OrbiQuant API server running on port ${PORT}`); - logger.info(`📡 WebSocket server running on ws://localhost:${PORT}/ws`); - logger.info(`📚 Environment: ${config.app.env}`); - logger.info(`📖 Docs available at http://localhost:${PORT}/api/v1/docs`); -}); - -// Graceful shutdown -const gracefulShutdown = () => { - logger.info('Shutting down gracefully...'); - tradingStreamService.shutdown(); - wsManager.shutdown(); - httpServer.close(() => { - logger.info('HTTP server closed'); - process.exit(0); - }); -}; - -process.on('SIGTERM', () => { - logger.info('SIGTERM received.'); - gracefulShutdown(); -}); - -process.on('SIGINT', () => { - logger.info('SIGINT received.'); - gracefulShutdown(); -}); - -export { app }; diff --git a/apps/backend/src/modules/admin/admin.routes.ts b/apps/backend/src/modules/admin/admin.routes.ts deleted file mode 100644 index 0f594a3..0000000 --- a/apps/backend/src/modules/admin/admin.routes.ts +++ /dev/null @@ -1,431 +0,0 @@ -/** - * Admin Routes - * Admin-only endpoints for dashboard, user management, system health, and audit logs - */ - -import { Router, Request, Response, NextFunction } from 'express'; -import { mlEngineClient, tradingAgentsClient } from '../../shared/clients/index.js'; - -const router = Router(); - -// ============================================================================ -// Dashboard -// ============================================================================ - -/** - * GET /api/v1/admin/dashboard - * Get dashboard statistics - */ -router.get('/dashboard', async (req: Request, res: Response, next: NextFunction) => { - try { - // Mock stats for development - replace with actual DB queries in production - const stats = { - users: { - total_users: 150, - active_users: 142, - new_users_week: 12, - new_users_month: 45, - }, - trading: { - total_trades: 1256, - trades_today: 48, - winning_trades: 723, - avg_pnl: 125.50, - }, - models: { - total_models: 6, - active_models: 5, - predictions_today: 1247, - overall_accuracy: 0.68, - }, - agents: { - total_agents: 3, - active_agents: 1, - signals_today: 24, - }, - pnl: { - today: 1250.75, - week: 8456.32, - month: 32145.89, - }, - system: { - uptime: process.uptime(), - memory: process.memoryUsage(), - version: process.env.npm_package_version || '1.0.0', - }, - timestamp: new Date().toISOString(), - }; - - res.json({ - success: true, - data: { - total_models: stats.models.total_models, - active_models: stats.models.active_models, - total_predictions_today: stats.models.predictions_today, - total_predictions_week: stats.models.predictions_today * 7, - overall_accuracy: stats.models.overall_accuracy, - total_agents: stats.agents.total_agents, - active_agents: stats.agents.active_agents, - total_signals_today: stats.agents.signals_today, - total_pnl_today: stats.pnl.today, - total_pnl_week: stats.pnl.week, - total_pnl_month: stats.pnl.month, - system_health: 'healthy', - users: stats.users, - trading: stats.trading, - system: stats.system, - }, - }); - } catch (error) { - next(error); - } -}); - -// ============================================================================ -// System Health -// ============================================================================ - -/** - * GET /api/v1/admin/system/health - * Get system-wide health status - */ -router.get('/system/health', async (req: Request, res: Response, next: NextFunction) => { - try { - // Check ML Engine - let mlHealth = 'unknown'; - let mlLatency = 0; - try { - const mlStart = Date.now(); - await mlEngineClient.healthCheck(); - mlLatency = Date.now() - mlStart; - mlHealth = 'healthy'; - } catch { - mlHealth = 'unhealthy'; - } - - // Check Trading Agents - let agentsHealth = 'unknown'; - let agentsLatency = 0; - try { - const agentsStart = Date.now(); - await tradingAgentsClient.healthCheck(); - agentsLatency = Date.now() - agentsStart; - agentsHealth = 'healthy'; - } catch { - agentsHealth = 'unhealthy'; - } - - const overallHealth = (mlHealth === 'healthy' && agentsHealth === 'healthy') ? 'healthy' : 'degraded'; - - const health = { - status: overallHealth, - services: { - database: { - status: 'healthy', // Mock for now - add actual DB check - latency: 5, - }, - mlEngine: { - status: mlHealth, - latency: mlLatency, - }, - tradingAgents: { - status: agentsHealth, - latency: agentsLatency, - }, - redis: { - status: 'healthy', // Mock for now - latency: 2, - }, - }, - system: { - uptime: process.uptime(), - memory: { - used: process.memoryUsage().heapUsed, - total: process.memoryUsage().heapTotal, - percentage: (process.memoryUsage().heapUsed / process.memoryUsage().heapTotal) * 100, - }, - cpu: process.cpuUsage(), - }, - timestamp: new Date().toISOString(), - }; - - res.json({ - success: true, - data: health, - }); - } catch (error) { - next(error); - } -}); - -// ============================================================================ -// Users Management -// ============================================================================ - -/** - * GET /api/v1/admin/users - * List all users with filters and pagination - */ -router.get('/users', async (req: Request, res: Response, next: NextFunction) => { - try { - const { page = 1, limit = 20, status, role, search } = req.query; - - // Mock users data for development - const mockUsers = [ - { - id: '1', - email: 'admin@orbiquant.local', - role: 'admin', - status: 'active', - created_at: new Date().toISOString(), - full_name: 'Admin OrbiQuant', - }, - { - id: '2', - email: 'trader1@example.com', - role: 'premium', - status: 'active', - created_at: new Date().toISOString(), - full_name: 'Trader One', - }, - { - id: '3', - email: 'trader2@example.com', - role: 'user', - status: 'active', - created_at: new Date().toISOString(), - full_name: 'Trader Two', - }, - ]; - - let filteredUsers = mockUsers; - - if (status) { - filteredUsers = filteredUsers.filter(u => u.status === status); - } - if (role) { - filteredUsers = filteredUsers.filter(u => u.role === role); - } - if (search) { - const searchLower = (search as string).toLowerCase(); - filteredUsers = filteredUsers.filter(u => - u.email.toLowerCase().includes(searchLower) || - u.full_name.toLowerCase().includes(searchLower) - ); - } - - const total = filteredUsers.length; - const start = (Number(page) - 1) * Number(limit); - const paginatedUsers = filteredUsers.slice(start, start + Number(limit)); - - res.json({ - success: true, - data: paginatedUsers, - meta: { - total, - page: Number(page), - limit: Number(limit), - totalPages: Math.ceil(total / Number(limit)), - }, - }); - } catch (error) { - next(error); - } -}); - -/** - * GET /api/v1/admin/users/:id - * Get user details by ID - */ -router.get('/users/:id', async (req: Request, res: Response, next: NextFunction) => { - try { - const { id } = req.params; - - // Mock user data - const user = { - id, - email: 'admin@orbiquant.local', - role: 'admin', - status: 'active', - created_at: new Date().toISOString(), - full_name: 'Admin OrbiQuant', - avatar_url: null, - bio: 'Platform administrator', - location: 'Remote', - }; - - res.json({ - success: true, - data: user, - }); - } catch (error) { - next(error); - } -}); - -/** - * PATCH /api/v1/admin/users/:id/status - * Update user status - */ -router.patch('/users/:id/status', async (req: Request, res: Response, next: NextFunction) => { - try { - const { id } = req.params; - const { status, reason } = req.body; - - if (!['active', 'suspended', 'banned'].includes(status)) { - res.status(400).json({ - success: false, - error: { message: 'Invalid status value', code: 'VALIDATION_ERROR' }, - }); - return; - } - - // Mock update - replace with actual DB update - res.json({ - success: true, - data: { - id, - status, - updated_at: new Date().toISOString(), - }, - }); - } catch (error) { - next(error); - } -}); - -/** - * PATCH /api/v1/admin/users/:id/role - * Update user role - */ -router.patch('/users/:id/role', async (req: Request, res: Response, next: NextFunction) => { - try { - const { id } = req.params; - const { role } = req.body; - - if (!['user', 'premium', 'admin'].includes(role)) { - res.status(400).json({ - success: false, - error: { message: 'Invalid role value', code: 'VALIDATION_ERROR' }, - }); - return; - } - - // Mock update - replace with actual DB update - res.json({ - success: true, - data: { - id, - role, - updated_at: new Date().toISOString(), - }, - }); - } catch (error) { - next(error); - } -}); - -// ============================================================================ -// Audit Logs -// ============================================================================ - -/** - * GET /api/v1/admin/audit/logs - * Get audit logs with filters - */ -router.get('/audit/logs', async (req: Request, res: Response, next: NextFunction) => { - try { - const { page = 1, limit = 50, userId, action, startDate, endDate } = req.query; - - // Mock audit logs - const mockLogs = [ - { - id: '1', - user_id: '1', - action: 'LOGIN', - resource: 'auth', - details: { ip: '192.168.1.1' }, - ip_address: '192.168.1.1', - created_at: new Date().toISOString(), - }, - { - id: '2', - user_id: '1', - action: 'UPDATE_SETTINGS', - resource: 'users', - details: { theme: 'dark' }, - ip_address: '192.168.1.1', - created_at: new Date(Date.now() - 3600000).toISOString(), - }, - { - id: '3', - user_id: '1', - action: 'CREATE_SIGNAL', - resource: 'trading', - details: { symbol: 'XAUUSD', direction: 'long' }, - ip_address: '192.168.1.1', - created_at: new Date(Date.now() - 7200000).toISOString(), - }, - ]; - - let filteredLogs = mockLogs; - - if (userId) { - filteredLogs = filteredLogs.filter(l => l.user_id === userId); - } - if (action) { - filteredLogs = filteredLogs.filter(l => l.action === action); - } - - const total = filteredLogs.length; - const start = (Number(page) - 1) * Number(limit); - const paginatedLogs = filteredLogs.slice(start, start + Number(limit)); - - res.json({ - success: true, - data: paginatedLogs, - meta: { - total, - page: Number(page), - limit: Number(limit), - totalPages: Math.ceil(total / Number(limit)), - }, - }); - } catch (error) { - next(error); - } -}); - -// ============================================================================ -// Stats Endpoint (for admin dashboard widget) -// ============================================================================ - -/** - * GET /api/v1/admin/stats - * Get admin stats (alias for dashboard endpoint) - */ -router.get('/stats', async (req: Request, res: Response, next: NextFunction) => { - try { - res.json({ - success: true, - data: { - total_models: 6, - active_models: 5, - total_predictions_today: 1247, - total_predictions_week: 8729, - overall_accuracy: 0.68, - total_agents: 3, - active_agents: 1, - total_signals_today: 24, - total_pnl_today: 1250.75, - total_pnl_week: 8456.32, - total_pnl_month: 32145.89, - system_health: 'healthy', - }, - }); - } catch (error) { - next(error); - } -}); - -export { router as adminRouter }; diff --git a/apps/backend/src/modules/agents/agents.routes.ts b/apps/backend/src/modules/agents/agents.routes.ts deleted file mode 100644 index 86086cd..0000000 --- a/apps/backend/src/modules/agents/agents.routes.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * Trading Agents Routes - * Routes for managing trading agents (Atlas, Orion, Nova) - */ - -import { Router, RequestHandler } from 'express'; -import * as agentsController from './controllers/agents.controller'; - -const router = Router(); - -// Type cast helper for authenticated routes -// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -const authHandler = (fn: Function): RequestHandler => fn as RequestHandler; - -// ============================================================================ -// Health & Status (Public) -// ============================================================================ - -/** - * GET /api/v1/agents/health - * Get Trading Agents service health - */ -router.get('/health', agentsController.getHealth); - -/** - * GET /api/v1/agents/connection - * Check Trading Agents connection - */ -router.get('/connection', agentsController.checkConnection); - -/** - * GET /api/v1/agents/summary - * Get all agents summary - */ -router.get('/summary', agentsController.getAllAgentsSummary); - -// ============================================================================ -// Agent Lifecycle (Authenticated) -// ============================================================================ - -/** - * POST /api/v1/agents/:agentType/start - * Start a trading agent - * Body: { initialEquity, symbols?, riskPerTrade?, maxPositions? } - */ -router.post('/:agentType/start', authHandler(agentsController.startAgent)); - -/** - * POST /api/v1/agents/:agentType/stop - * Stop a trading agent - */ -router.post('/:agentType/stop', authHandler(agentsController.stopAgent)); - -/** - * POST /api/v1/agents/:agentType/pause - * Pause a trading agent - */ -router.post('/:agentType/pause', authHandler(agentsController.pauseAgent)); - -/** - * POST /api/v1/agents/:agentType/resume - * Resume a trading agent - */ -router.post('/:agentType/resume', authHandler(agentsController.resumeAgent)); - -// ============================================================================ -// Agent Status & Metrics (Public) -// ============================================================================ - -/** - * GET /api/v1/agents/:agentType/status - * Get agent status - */ -router.get('/:agentType/status', agentsController.getAgentStatus); - -/** - * GET /api/v1/agents/:agentType/metrics - * Get agent metrics - */ -router.get('/:agentType/metrics', agentsController.getAgentMetrics); - -// ============================================================================ -// Positions & Trades -// ============================================================================ - -/** - * GET /api/v1/agents/:agentType/positions - * Get agent positions - */ -router.get('/:agentType/positions', agentsController.getAgentPositions); - -/** - * GET /api/v1/agents/:agentType/trades - * Get agent trades - * Query params: limit, offset, symbol - */ -router.get('/:agentType/trades', agentsController.getAgentTrades); - -/** - * POST /api/v1/agents/:agentType/positions/:positionId/close - * Close a specific position - */ -router.post('/:agentType/positions/:positionId/close', authHandler(agentsController.closePosition)); - -/** - * POST /api/v1/agents/:agentType/positions/close-all - * Close all positions - */ -router.post('/:agentType/positions/close-all', authHandler(agentsController.closeAllPositions)); - -// ============================================================================ -// Signals (Authenticated) -// ============================================================================ - -/** - * POST /api/v1/agents/:agentType/signal - * Send signal to an agent - * Body: { symbol, action, confidence, price, stopLoss?, takeProfit? } - */ -router.post('/:agentType/signal', authHandler(agentsController.sendSignal)); - -/** - * POST /api/v1/agents/signals/broadcast - * Broadcast signal to all running agents - * Body: { symbol, action, confidence, price, stopLoss?, takeProfit? } - */ -router.post('/signals/broadcast', authHandler(agentsController.broadcastSignal)); - -export { router as agentsRouter }; diff --git a/apps/backend/src/modules/agents/controllers/agents.controller.ts b/apps/backend/src/modules/agents/controllers/agents.controller.ts deleted file mode 100644 index 6e93e5f..0000000 --- a/apps/backend/src/modules/agents/controllers/agents.controller.ts +++ /dev/null @@ -1,504 +0,0 @@ -/** - * Agents Controller - * Handles Trading Agents integration endpoints - */ - -import { Request, Response, NextFunction } from 'express'; -import { agentsService, StartAgentRequest } from '../services/agents.service'; -import { AgentType, SignalInput } from '../../../shared/clients'; - -// ============================================================================ -// Types -// ============================================================================ - -type AuthRequest = Request; - -// ============================================================================ -// Health & Status -// ============================================================================ - -/** - * Get Trading Agents service health - */ -export async function getHealth(req: Request, res: Response, _next: NextFunction): Promise { - try { - const health = await agentsService.getHealth(); - - res.json({ - success: true, - data: health, - }); - } catch { - // Service unavailable - res.json({ - success: true, - data: { - status: 'unavailable', - message: 'Trading Agents service is not running', - }, - }); - } -} - -/** - * Check Trading Agents connection - */ -export async function checkConnection(req: Request, res: Response, next: NextFunction): Promise { - try { - const isAvailable = await agentsService.isAvailable(); - - res.json({ - success: true, - data: { connected: isAvailable }, - }); - } catch (error) { - next(error); - } -} - -/** - * Get all agents summary - */ -export async function getAllAgentsSummary(req: Request, res: Response, next: NextFunction): Promise { - try { - const summaries = await agentsService.getAllAgentsSummary(); - - res.json({ - success: true, - data: summaries, - }); - } catch (error) { - next(error); - } -} - -// ============================================================================ -// Agent Lifecycle -// ============================================================================ - -/** - * Start a trading agent - */ -export async function startAgent(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const { agentType } = req.params; - const { initialEquity, symbols, riskPerTrade, maxPositions } = req.body; - - if (!agentType || !['atlas', 'orion', 'nova'].includes(agentType)) { - res.status(400).json({ - success: false, - error: { message: 'Invalid agent type', code: 'VALIDATION_ERROR' }, - }); - return; - } - - if (!initialEquity || initialEquity <= 0) { - res.status(400).json({ - success: false, - error: { message: 'Initial equity is required and must be positive', code: 'VALIDATION_ERROR' }, - }); - return; - } - - const request: StartAgentRequest = { - agentType: agentType as AgentType, - initialEquity, - symbols, - riskPerTrade, - maxPositions, - }; - - const status = await agentsService.startAgent(request); - - res.status(201).json({ - success: true, - data: status, - }); - } catch (error) { - next(error); - } -} - -/** - * Stop a trading agent - */ -export async function stopAgent(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const { agentType } = req.params; - - if (!agentType || !['atlas', 'orion', 'nova'].includes(agentType)) { - res.status(400).json({ - success: false, - error: { message: 'Invalid agent type', code: 'VALIDATION_ERROR' }, - }); - return; - } - - const status = await agentsService.stopAgent(agentType as AgentType); - - res.json({ - success: true, - data: status, - }); - } catch (error) { - next(error); - } -} - -/** - * Pause a trading agent - */ -export async function pauseAgent(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const { agentType } = req.params; - - if (!agentType || !['atlas', 'orion', 'nova'].includes(agentType)) { - res.status(400).json({ - success: false, - error: { message: 'Invalid agent type', code: 'VALIDATION_ERROR' }, - }); - return; - } - - const status = await agentsService.pauseAgent(agentType as AgentType); - - res.json({ - success: true, - data: status, - }); - } catch (error) { - next(error); - } -} - -/** - * Resume a trading agent - */ -export async function resumeAgent(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const { agentType } = req.params; - - if (!agentType || !['atlas', 'orion', 'nova'].includes(agentType)) { - res.status(400).json({ - success: false, - error: { message: 'Invalid agent type', code: 'VALIDATION_ERROR' }, - }); - return; - } - - const status = await agentsService.resumeAgent(agentType as AgentType); - - res.json({ - success: true, - data: status, - }); - } catch (error) { - next(error); - } -} - -// ============================================================================ -// Agent Status & Metrics -// ============================================================================ - -/** - * Get agent status - */ -export async function getAgentStatus(req: Request, res: Response, next: NextFunction): Promise { - try { - const { agentType } = req.params; - - if (!agentType || !['atlas', 'orion', 'nova'].includes(agentType)) { - res.status(400).json({ - success: false, - error: { message: 'Invalid agent type', code: 'VALIDATION_ERROR' }, - }); - return; - } - - const status = await agentsService.getAgentStatus(agentType as AgentType); - - res.json({ - success: true, - data: status, - }); - } catch (error) { - next(error); - } -} - -/** - * Get agent metrics - */ -export async function getAgentMetrics(req: Request, res: Response, next: NextFunction): Promise { - try { - const { agentType } = req.params; - - if (!agentType || !['atlas', 'orion', 'nova'].includes(agentType)) { - res.status(400).json({ - success: false, - error: { message: 'Invalid agent type', code: 'VALIDATION_ERROR' }, - }); - return; - } - - const metrics = await agentsService.getAgentMetrics(agentType as AgentType); - - res.json({ - success: true, - data: metrics, - }); - } catch (error) { - next(error); - } -} - -// ============================================================================ -// Positions & Trades -// ============================================================================ - -/** - * Get agent positions - */ -export async function getAgentPositions(req: Request, res: Response, next: NextFunction): Promise { - try { - const { agentType } = req.params; - - if (!agentType || !['atlas', 'orion', 'nova'].includes(agentType)) { - res.status(400).json({ - success: false, - error: { message: 'Invalid agent type', code: 'VALIDATION_ERROR' }, - }); - return; - } - - const positions = await agentsService.getAgentPositions(agentType as AgentType); - - res.json({ - success: true, - data: positions, - }); - } catch (error) { - next(error); - } -} - -/** - * Get agent trades - */ -export async function getAgentTrades(req: Request, res: Response, next: NextFunction): Promise { - try { - const { agentType } = req.params; - const { limit, offset, symbol } = req.query; - - if (!agentType || !['atlas', 'orion', 'nova'].includes(agentType)) { - res.status(400).json({ - success: false, - error: { message: 'Invalid agent type', code: 'VALIDATION_ERROR' }, - }); - return; - } - - const trades = await agentsService.getAgentTrades(agentType as AgentType, { - limit: limit ? Number(limit) : undefined, - offset: offset ? Number(offset) : undefined, - symbol: symbol as string | undefined, - }); - - res.json({ - success: true, - data: trades, - }); - } catch (error) { - next(error); - } -} - -/** - * Close a position - */ -export async function closePosition(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const { agentType, positionId } = req.params; - - if (!agentType || !['atlas', 'orion', 'nova'].includes(agentType)) { - res.status(400).json({ - success: false, - error: { message: 'Invalid agent type', code: 'VALIDATION_ERROR' }, - }); - return; - } - - const trade = await agentsService.closePosition(agentType as AgentType, positionId); - - res.json({ - success: true, - data: trade, - }); - } catch (error) { - next(error); - } -} - -/** - * Close all positions - */ -export async function closeAllPositions(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const { agentType } = req.params; - - if (!agentType || !['atlas', 'orion', 'nova'].includes(agentType)) { - res.status(400).json({ - success: false, - error: { message: 'Invalid agent type', code: 'VALIDATION_ERROR' }, - }); - return; - } - - const result = await agentsService.closeAllPositions(agentType as AgentType); - - res.json({ - success: true, - data: result, - }); - } catch (error) { - next(error); - } -} - -// ============================================================================ -// Signals -// ============================================================================ - -/** - * Send signal to agent - */ -export async function sendSignal(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const { agentType } = req.params; - const signal: SignalInput = req.body; - - if (!agentType || !['atlas', 'orion', 'nova'].includes(agentType)) { - res.status(400).json({ - success: false, - error: { message: 'Invalid agent type', code: 'VALIDATION_ERROR' }, - }); - return; - } - - if (!signal.symbol || !signal.action || signal.confidence === undefined) { - res.status(400).json({ - success: false, - error: { message: 'Signal requires symbol, action, confidence, and price', code: 'VALIDATION_ERROR' }, - }); - return; - } - - const result = await agentsService.sendSignal(agentType as AgentType, signal); - - res.json({ - success: true, - data: result, - }); - } catch (error) { - next(error); - } -} - -/** - * Broadcast signal to all agents - */ -export async function broadcastSignal(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const signal: SignalInput = req.body; - - if (!signal.symbol || !signal.action || signal.confidence === undefined) { - res.status(400).json({ - success: false, - error: { message: 'Signal requires symbol, action, confidence, and price', code: 'VALIDATION_ERROR' }, - }); - return; - } - - const result = await agentsService.broadcastSignal(signal); - - res.json({ - success: true, - data: result, - }); - } catch (error) { - next(error); - } -} diff --git a/apps/backend/src/modules/agents/services/agents.service.ts b/apps/backend/src/modules/agents/services/agents.service.ts deleted file mode 100644 index 6aaded4..0000000 --- a/apps/backend/src/modules/agents/services/agents.service.ts +++ /dev/null @@ -1,230 +0,0 @@ -/** - * Agents Service - * Business logic for Trading Agents integration - */ - -import { - tradingAgentsClient, - AgentType, - AgentConfig, - AgentStatusResponse, - AgentMetrics, - AgentPosition, - AgentTrade, - SignalInput, -} from '../../../shared/clients'; -import { logger } from '../../../shared/utils/logger'; - -// ============================================================================ -// Types -// ============================================================================ - -export interface StartAgentRequest { - agentType: AgentType; - initialEquity: number; - symbols?: string[]; - riskPerTrade?: number; - maxPositions?: number; -} - -export interface AgentSummary { - name: AgentType; - status: string; - equity: number; - positions: number; - todayPnl: number; - winRate?: number; - totalTrades?: number; -} - -// ============================================================================ -// Service Implementation -// ============================================================================ - -class AgentsService { - /** - * Check if Trading Agents service is available - */ - async isAvailable(): Promise { - return tradingAgentsClient.isAvailable(); - } - - /** - * Get health status of Trading Agents service - */ - async getHealth() { - return tradingAgentsClient.healthCheck(); - } - - /** - * Start a trading agent - */ - async startAgent(request: StartAgentRequest): Promise { - const config: AgentConfig = { - name: request.agentType, - initial_equity: request.initialEquity, - symbols: request.symbols, - risk_per_trade: request.riskPerTrade, - max_positions: request.maxPositions, - }; - - logger.info('[AgentsService] Starting agent', { - agentType: request.agentType, - initialEquity: request.initialEquity, - }); - - return tradingAgentsClient.startAgent(request.agentType, config); - } - - /** - * Stop a trading agent - */ - async stopAgent(agentType: AgentType): Promise { - logger.info('[AgentsService] Stopping agent', { agentType }); - return tradingAgentsClient.stopAgent(agentType); - } - - /** - * Pause a trading agent - */ - async pauseAgent(agentType: AgentType): Promise { - logger.info('[AgentsService] Pausing agent', { agentType }); - return tradingAgentsClient.pauseAgent(agentType); - } - - /** - * Resume a trading agent - */ - async resumeAgent(agentType: AgentType): Promise { - logger.info('[AgentsService] Resuming agent', { agentType }); - return tradingAgentsClient.resumeAgent(agentType); - } - - /** - * Get agent status - */ - async getAgentStatus(agentType: AgentType): Promise { - return tradingAgentsClient.getAgentStatus(agentType); - } - - /** - * Get agent metrics - */ - async getAgentMetrics(agentType: AgentType): Promise { - return tradingAgentsClient.getAgentMetrics(agentType); - } - - /** - * Get all agents summary - */ - async getAllAgentsSummary(): Promise { - const agents: AgentType[] = ['atlas', 'orion', 'nova']; - const summaries: AgentSummary[] = []; - - for (const agentType of agents) { - try { - const status = await tradingAgentsClient.getAgentStatus(agentType); - const metrics = status.status === 'running' - ? await tradingAgentsClient.getAgentMetrics(agentType) - : null; - - summaries.push({ - name: agentType, - status: status.status, - equity: status.equity, - positions: status.positions, - todayPnl: status.today_pnl, - winRate: metrics?.win_rate, - totalTrades: metrics?.total_trades, - }); - } catch { - // Agent service might not be running - summaries.push({ - name: agentType, - status: 'unavailable', - equity: 0, - positions: 0, - todayPnl: 0, - }); - } - } - - return summaries; - } - - /** - * Get agent positions - */ - async getAgentPositions(agentType: AgentType): Promise { - return tradingAgentsClient.getPositions(agentType); - } - - /** - * Get agent trades history - */ - async getAgentTrades( - agentType: AgentType, - options?: { limit?: number; offset?: number; symbol?: string } - ): Promise { - return tradingAgentsClient.getTrades(agentType, options); - } - - /** - * Close a specific position - */ - async closePosition(agentType: AgentType, positionId: string): Promise { - logger.info('[AgentsService] Closing position', { agentType, positionId }); - return tradingAgentsClient.closePosition(agentType, positionId); - } - - /** - * Close all positions for an agent - */ - async closeAllPositions(agentType: AgentType): Promise<{ closed: number }> { - logger.info('[AgentsService] Closing all positions', { agentType }); - return tradingAgentsClient.closeAllPositions(agentType); - } - - /** - * Send a signal to an agent - */ - async sendSignal(agentType: AgentType, signal: SignalInput): Promise<{ received: boolean }> { - logger.debug('[AgentsService] Sending signal', { - agentType, - symbol: signal.symbol, - action: signal.action, - }); - return tradingAgentsClient.sendSignal(agentType, signal); - } - - /** - * Broadcast signal to all running agents - */ - async broadcastSignal(signal: SignalInput): Promise<{ agents_notified: number }> { - logger.info('[AgentsService] Broadcasting signal', { - symbol: signal.symbol, - action: signal.action, - }); - return tradingAgentsClient.broadcastSignal(signal); - } - - /** - * Map product ID to agent type - * Used for Investment module integration - */ - getAgentTypeByProduct(productId: string): AgentType { - // Product mapping logic - // Conservative products -> Atlas - // Moderate products -> Orion - // Aggressive products -> Nova - if (productId.includes('conservative') || productId.includes('atlas')) { - return 'atlas'; - } else if (productId.includes('aggressive') || productId.includes('nova')) { - return 'nova'; - } - return 'orion'; // Default to moderate - } -} - -// Export singleton -export const agentsService = new AgentsService(); diff --git a/apps/backend/src/modules/auth/auth.routes.ts b/apps/backend/src/modules/auth/auth.routes.ts deleted file mode 100644 index 06ff756..0000000 --- a/apps/backend/src/modules/auth/auth.routes.ts +++ /dev/null @@ -1,305 +0,0 @@ -// ============================================================================ -// OrbiQuant IA - Auth Routes -// ============================================================================ - -import { Router } from 'express'; -import { validationResult } from 'express-validator'; -import { Request, Response, NextFunction } from 'express'; -import { authRateLimiter, strictRateLimiter } from '../../core/middleware/rate-limiter'; -import { authenticate } from '../../core/middleware/auth.middleware'; -import * as authController from './controllers/auth.controller'; -import * as validators from './validators/auth.validators'; - -const router = Router(); - -// Validation middleware -const validate = (req: Request, res: Response, next: NextFunction) => { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ - success: false, - errors: errors.array().map((e) => ({ - field: e.type === 'field' ? (e as Record).path as string | undefined : undefined, - message: e.msg, - })), - }); - } - next(); -}; - -// Apply rate limiting -router.use(authRateLimiter); - -// ============================================================================ -// Email/Password Authentication -// ============================================================================ - -/** - * POST /api/v1/auth/register - * Register a new user with email/password - */ -router.post( - '/register', - strictRateLimiter, - validators.registerValidator, - validate, - authController.register -); - -/** - * POST /api/v1/auth/login - * Login with email/password - */ -router.post( - '/login', - strictRateLimiter, - validators.loginValidator, - validate, - authController.login -); - -/** - * POST /api/v1/auth/verify-email - * Verify email with token - */ -router.post( - '/verify-email', - validators.tokenValidator, - validate, - authController.verifyEmail -); - -/** - * POST /api/v1/auth/forgot-password - * Request password reset email - */ -router.post( - '/forgot-password', - strictRateLimiter, - validators.emailValidator, - validate, - authController.forgotPassword -); - -/** - * POST /api/v1/auth/reset-password - * Reset password with token - */ -router.post( - '/reset-password', - validators.resetPasswordValidator, - validate, - authController.resetPassword -); - -/** - * POST /api/v1/auth/change-password - * Change password (authenticated) - */ -router.post( - '/change-password', - authenticate, - validators.changePasswordValidator, - validate, - authController.changePassword -); - -// ============================================================================ -// Phone Authentication (SMS/WhatsApp) -// ============================================================================ - -/** - * POST /api/v1/auth/phone/send-otp - * Send OTP to phone via SMS or WhatsApp - */ -router.post( - '/phone/send-otp', - strictRateLimiter, - validators.phoneOTPValidator, - validate, - authController.sendPhoneOTP -); - -/** - * POST /api/v1/auth/phone/verify-otp - * Verify OTP and login/register - */ -router.post( - '/phone/verify-otp', - validators.verifyPhoneOTPValidator, - validate, - authController.verifyPhoneOTP -); - -// ============================================================================ -// OAuth Authentication -// ============================================================================ - -/** - * GET /api/v1/auth/:provider - * Get OAuth authorization URL - */ -router.get( - '/:provider', - validators.oauthProviderValidator, - validate, - authController.getOAuthUrl -); - -/** - * GET /api/v1/auth/:provider/callback - * OAuth callback handler - */ -router.get( - '/:provider/callback', - validators.oauthProviderValidator, - validate, - authController.handleOAuthCallback -); - -/** - * POST /api/v1/auth/:provider/callback - * OAuth callback handler (POST for Apple) - */ -router.post( - '/:provider/callback', - validators.oauthProviderValidator, - validate, - authController.handleOAuthCallback -); - -/** - * POST /api/v1/auth/:provider/token - * Verify OAuth token directly (for mobile apps) - */ -router.post( - '/:provider/token', - validators.oauthProviderValidator, - validate, - authController.verifyOAuthToken -); - -// ============================================================================ -// Token Management -// ============================================================================ - -/** - * POST /api/v1/auth/refresh - * Refresh access token - */ -router.post( - '/refresh', - validators.refreshTokenValidator, - validate, - authController.refreshToken -); - -/** - * POST /api/v1/auth/logout - * Logout current session - */ -router.post('/logout', authenticate, authController.logout); - -/** - * POST /api/v1/auth/logout-all - * Logout all sessions - */ -router.post('/logout-all', authenticate, authController.logoutAll); - -/** - * GET /api/v1/auth/sessions - * Get active sessions - */ -router.get('/sessions', authenticate, authController.getSessions); - -/** - * DELETE /api/v1/auth/sessions/:sessionId - * Revoke specific session - */ -router.delete( - '/sessions/:sessionId', - authenticate, - validators.sessionIdValidator, - validate, - authController.revokeSession -); - -// ============================================================================ -// Two-Factor Authentication -// ============================================================================ - -/** - * POST /api/v1/auth/2fa/setup - * Set up 2FA (get QR code and backup codes) - */ -router.post('/2fa/setup', authenticate, authController.setup2FA); - -/** - * POST /api/v1/auth/2fa/enable - * Enable 2FA after setup - */ -router.post( - '/2fa/enable', - authenticate, - validators.totpCodeValidator, - validate, - authController.enable2FA -); - -/** - * POST /api/v1/auth/2fa/disable - * Disable 2FA - */ -router.post( - '/2fa/disable', - authenticate, - validators.totpCodeValidator, - validate, - authController.disable2FA -); - -/** - * POST /api/v1/auth/2fa/backup-codes - * Regenerate backup codes - */ -router.post( - '/2fa/backup-codes', - authenticate, - validators.totpCodeValidator, - validate, - authController.regenerateBackupCodes -); - -// ============================================================================ -// Account Linking -// ============================================================================ - -/** - * GET /api/v1/auth/linked-accounts - * Get linked OAuth accounts - */ -router.get('/linked-accounts', authenticate, authController.getLinkedAccounts); - -/** - * DELETE /api/v1/auth/linked-accounts/:provider - * Unlink OAuth account - */ -router.delete( - '/linked-accounts/:provider', - authenticate, - validators.oauthProviderValidator, - validate, - authController.unlinkAccount -); - -// ============================================================================ -// Current User -// ============================================================================ - -/** - * GET /api/v1/auth/me - * Get current authenticated user - */ -router.get('/me', authenticate, authController.getCurrentUser); - -export { router as authRouter }; diff --git a/apps/backend/src/modules/auth/controllers/auth.controller.ts b/apps/backend/src/modules/auth/controllers/auth.controller.ts deleted file mode 100644 index 5761e1a..0000000 --- a/apps/backend/src/modules/auth/controllers/auth.controller.ts +++ /dev/null @@ -1,570 +0,0 @@ -// ============================================================================ -// OrbiQuant IA - Auth Controller -// ============================================================================ - -import { Request, Response, NextFunction } from 'express'; -import { emailService } from '../services/email.service'; -import { oauthService } from '../services/oauth.service'; -import { phoneService } from '../services/phone.service'; -import { twoFactorService } from '../services/twofa.service'; -import { tokenService } from '../services/token.service'; -import { config } from '../../../config'; -import { logger } from '../../../shared/utils/logger'; -import type { AuthProvider } from '../types/auth.types'; - -// Helper to get client info -const getClientInfo = (req: Request) => ({ - userAgent: req.headers['user-agent'], - ipAddress: req.ip || req.socket.remoteAddress, -}); - -// ============================================================================ -// Email/Password Authentication -// ============================================================================ - -export const register = async (req: Request, res: Response, next: NextFunction) => { - try { - const { email, password, firstName, lastName, acceptTerms } = req.body; - const { userAgent, ipAddress } = getClientInfo(req); - - const result = await emailService.register( - { email, password, firstName, lastName, acceptTerms }, - userAgent, - ipAddress - ); - - res.status(201).json({ - success: true, - message: result.message, - data: { userId: result.userId }, - }); - } catch (error) { - next(error); - } -}; - -export const login = async (req: Request, res: Response, next: NextFunction) => { - try { - const { email, password, totpCode, rememberMe } = req.body; - const { userAgent, ipAddress } = getClientInfo(req); - - const result = await emailService.login( - { email, password, totpCode, rememberMe }, - userAgent, - ipAddress - ); - - if (result.requiresTwoFactor) { - return res.status(200).json({ - success: true, - requiresTwoFactor: true, - message: 'Please enter your 2FA code', - }); - } - - res.json({ - success: true, - data: result, - }); - } catch (error) { - next(error); - } -}; - -export const verifyEmail = async (req: Request, res: Response, next: NextFunction) => { - try { - const { token } = req.body; - - const result = await emailService.verifyEmail(token); - - res.json({ - success: true, - message: result.message, - }); - } catch (error) { - next(error); - } -}; - -export const forgotPassword = async (req: Request, res: Response, next: NextFunction) => { - try { - const { email } = req.body; - - const result = await emailService.sendPasswordResetEmail(email); - - res.json({ - success: true, - message: result.message, - }); - } catch (error) { - next(error); - } -}; - -export const resetPassword = async (req: Request, res: Response, next: NextFunction) => { - try { - const { token, password } = req.body; - - const result = await emailService.resetPassword(token, password); - - res.json({ - success: true, - message: result.message, - }); - } catch (error) { - next(error); - } -}; - -export const changePassword = async (req: Request, res: Response, next: NextFunction) => { - try { - const userId = req.user!.id; - const { currentPassword, newPassword } = req.body; - - const result = await emailService.changePassword(userId, currentPassword, newPassword); - - res.json({ - success: true, - message: result.message, - }); - } catch (error) { - next(error); - } -}; - -// ============================================================================ -// Phone Authentication (SMS/WhatsApp) -// ============================================================================ - -export const sendPhoneOTP = async (req: Request, res: Response, next: NextFunction) => { - try { - const { phoneNumber, countryCode, channel } = req.body; - - const result = await phoneService.sendOTP(phoneNumber, countryCode, channel); - - res.json({ - success: true, - message: result.message, - data: { expiresAt: result.expiresAt }, - }); - } catch (error) { - next(error); - } -}; - -export const verifyPhoneOTP = async (req: Request, res: Response, next: NextFunction) => { - try { - const { phoneNumber, countryCode, otpCode } = req.body; - const { userAgent, ipAddress } = getClientInfo(req); - - const result = await phoneService.verifyOTP( - phoneNumber, - countryCode, - otpCode, - userAgent, - ipAddress - ); - - res.json({ - success: true, - data: result, - }); - } catch (error) { - next(error); - } -}; - -// ============================================================================ -// OAuth Authentication -// ============================================================================ - -// Store OAuth state in memory (use Redis in production) -const oauthStates = new Map(); - -export const getOAuthUrl = async (req: Request, res: Response, next: NextFunction) => { - try { - const provider = req.params.provider as AuthProvider; - const { returnUrl } = req.query; - - const state = oauthService.generateState(); - const stateData: { codeVerifier?: string; returnUrl?: string } = { - returnUrl: returnUrl as string, - }; - - let authUrl: string; - - switch (provider) { - case 'google': - authUrl = oauthService.getGoogleAuthUrl(state); - break; - - case 'facebook': - authUrl = oauthService.getFacebookAuthUrl(state); - break; - - case 'twitter': { - const codeVerifier = oauthService.generateCodeVerifier(); - const codeChallenge = oauthService.generateCodeChallenge(codeVerifier); - stateData.codeVerifier = codeVerifier; - authUrl = oauthService.getTwitterAuthUrl(state, codeChallenge); - break; - } - - case 'apple': - authUrl = oauthService.getAppleAuthUrl(state); - break; - - case 'github': - authUrl = oauthService.getGitHubAuthUrl(state); - break; - - default: - return res.status(400).json({ - success: false, - error: 'Invalid OAuth provider', - }); - } - - // Store state - oauthStates.set(state, stateData); - - // Clean up old states after 10 minutes - setTimeout(() => oauthStates.delete(state), 10 * 60 * 1000); - - res.json({ - success: true, - data: { authUrl }, - }); - } catch (error) { - next(error); - } -}; - -export const handleOAuthCallback = async (req: Request, res: Response, _next: NextFunction) => { - try { - const provider = req.params.provider as AuthProvider; - const { code, state } = req.query; - const { userAgent, ipAddress } = getClientInfo(req); - - // Verify state - const stateData = oauthStates.get(state as string); - if (!stateData) { - return res.redirect(`${config.app.frontendUrl}/login?error=invalid_state`); - } - oauthStates.delete(state as string); - - let oauthData; - - switch (provider) { - case 'google': - oauthData = await oauthService.verifyGoogleToken(code as string); - break; - - case 'facebook': - oauthData = await oauthService.verifyFacebookToken(code as string); - break; - - case 'twitter': - if (!stateData.codeVerifier) { - return res.redirect(`${config.app.frontendUrl}/login?error=missing_code_verifier`); - } - oauthData = await oauthService.verifyTwitterToken(code as string, stateData.codeVerifier); - break; - - case 'apple': - oauthData = await oauthService.verifyAppleToken(code as string, req.query.id_token as string); - break; - - case 'github': - oauthData = await oauthService.verifyGitHubToken(code as string); - break; - - default: - return res.redirect(`${config.app.frontendUrl}/login?error=invalid_provider`); - } - - if (!oauthData) { - return res.redirect(`${config.app.frontendUrl}/login?error=oauth_failed`); - } - - // Handle OAuth login/registration - const result = await oauthService.handleOAuthCallback(oauthData, userAgent, ipAddress); - - // Redirect with tokens - const params = new URLSearchParams({ - accessToken: result.tokens.accessToken, - refreshToken: result.tokens.refreshToken, - isNewUser: result.isNewUser?.toString() || 'false', - }); - - const returnUrl = stateData.returnUrl || '/dashboard'; - res.redirect(`${config.app.frontendUrl}/auth/callback?${params}&returnUrl=${encodeURIComponent(returnUrl)}`); - } catch (error) { - logger.error('OAuth callback error', { error }); - res.redirect(`${config.app.frontendUrl}/login?error=oauth_error`); - } -}; - -// Mobile/SPA OAuth - verify token directly -export const verifyOAuthToken = async (req: Request, res: Response, next: NextFunction) => { - try { - const provider = req.params.provider as AuthProvider; - const { token } = req.body; - const { userAgent, ipAddress } = getClientInfo(req); - - let oauthData; - - switch (provider) { - case 'google': - // For mobile, we receive an ID token directly - oauthData = await oauthService.verifyGoogleIdToken(token); - break; - - // Other providers would need their mobile SDKs - default: - return res.status(400).json({ - success: false, - error: 'Provider not supported for direct token verification', - }); - } - - if (!oauthData) { - return res.status(401).json({ - success: false, - error: 'Invalid OAuth token', - }); - } - - const result = await oauthService.handleOAuthCallback(oauthData, userAgent, ipAddress); - - res.json({ - success: true, - data: result, - }); - } catch (error) { - next(error); - } -}; - -// ============================================================================ -// Token Management -// ============================================================================ - -export const refreshToken = async (req: Request, res: Response, next: NextFunction) => { - try { - const { refreshToken } = req.body; - - const tokens = await tokenService.refreshSession(refreshToken); - - if (!tokens) { - return res.status(401).json({ - success: false, - error: 'Invalid or expired refresh token', - }); - } - - res.json({ - success: true, - data: tokens, - }); - } catch (error) { - next(error); - } -}; - -export const logout = async (req: Request, res: Response, next: NextFunction) => { - try { - const userId = req.user!.id; - const sessionId = req.sessionId; - - if (sessionId) { - await tokenService.revokeSession(sessionId, userId); - } - - res.json({ - success: true, - message: 'Logged out successfully', - }); - } catch (error) { - next(error); - } -}; - -export const logoutAll = async (req: Request, res: Response, next: NextFunction) => { - try { - const userId = req.user!.id; - - const count = await tokenService.revokeAllUserSessions(userId); - - res.json({ - success: true, - message: `Logged out from ${count} sessions`, - }); - } catch (error) { - next(error); - } -}; - -export const getSessions = async (req: Request, res: Response, next: NextFunction) => { - try { - const userId = req.user!.id; - - const sessions = await tokenService.getActiveSessions(userId); - - res.json({ - success: true, - data: sessions.map((s) => ({ - id: s.id, - userAgent: s.userAgent, - ipAddress: s.ipAddress, - createdAt: s.createdAt, - lastActiveAt: s.lastActiveAt, - isCurrent: s.id === req.sessionId, - })), - }); - } catch (error) { - next(error); - } -}; - -export const revokeSession = async (req: Request, res: Response, next: NextFunction) => { - try { - const userId = req.user!.id; - const { sessionId } = req.params; - - const revoked = await tokenService.revokeSession(sessionId, userId); - - if (!revoked) { - return res.status(404).json({ - success: false, - error: 'Session not found', - }); - } - - res.json({ - success: true, - message: 'Session revoked', - }); - } catch (error) { - next(error); - } -}; - -// ============================================================================ -// Two-Factor Authentication -// ============================================================================ - -export const setup2FA = async (req: Request, res: Response, next: NextFunction) => { - try { - const userId = req.user!.id; - - const result = await twoFactorService.setupTOTP(userId); - - res.json({ - success: true, - data: result, - }); - } catch (error) { - next(error); - } -}; - -export const enable2FA = async (req: Request, res: Response, next: NextFunction) => { - try { - const userId = req.user!.id; - const { code } = req.body; - - const result = await twoFactorService.enableTOTP(userId, code); - - res.json({ - success: true, - message: result.message, - }); - } catch (error) { - next(error); - } -}; - -export const disable2FA = async (req: Request, res: Response, next: NextFunction) => { - try { - const userId = req.user!.id; - const { code } = req.body; - - const result = await twoFactorService.disableTOTP(userId, code); - - res.json({ - success: true, - message: result.message, - }); - } catch (error) { - next(error); - } -}; - -export const regenerateBackupCodes = async (req: Request, res: Response, next: NextFunction) => { - try { - const userId = req.user!.id; - const { code } = req.body; - - const result = await twoFactorService.regenerateBackupCodes(userId, code); - - res.json({ - success: true, - data: result, - }); - } catch (error) { - next(error); - } -}; - -// ============================================================================ -// Account Linking -// ============================================================================ - -export const getLinkedAccounts = async (req: Request, res: Response, next: NextFunction) => { - try { - const userId = req.user!.id; - - const accounts = await oauthService.getLinkedAccounts(userId); - - res.json({ - success: true, - data: accounts, - }); - } catch (error) { - next(error); - } -}; - -export const unlinkAccount = async (req: Request, res: Response, next: NextFunction) => { - try { - const userId = req.user!.id; - const provider = req.params.provider as AuthProvider; - - await oauthService.unlinkOAuthAccount(userId, provider); - - res.json({ - success: true, - message: `${provider} account unlinked`, - }); - } catch (error) { - next(error); - } -}; - -// ============================================================================ -// Current User -// ============================================================================ - -export const getCurrentUser = async (req: Request, res: Response, next: NextFunction) => { - try { - res.json({ - success: true, - data: { - user: req.user, - }, - }); - } catch (error) { - next(error); - } -}; diff --git a/apps/backend/src/modules/auth/controllers/email-auth.controller.ts b/apps/backend/src/modules/auth/controllers/email-auth.controller.ts deleted file mode 100644 index 94cd401..0000000 --- a/apps/backend/src/modules/auth/controllers/email-auth.controller.ts +++ /dev/null @@ -1,168 +0,0 @@ -/** - * EmailAuthController - * - * @description Controller for email/password authentication. - * Extracted from auth.controller.ts (P0-009: Auth Controller split). - * - * Routes: - * - POST /auth/register - Register new user - * - POST /auth/login - Login with email/password - * - POST /auth/verify-email - Verify email address - * - POST /auth/forgot-password - Request password reset - * - POST /auth/reset-password - Reset password with token - * - POST /auth/change-password - Change password (authenticated) - * - * @see OAuthController - OAuth authentication - * @see TwoFactorController - 2FA operations - * @see TokenController - Token management - */ -import { Request, Response, NextFunction } from 'express'; -import { emailService } from '../services/email.service'; - -/** - * Gets client info from request - */ -const getClientInfo = (req: Request) => ({ - userAgent: req.headers['user-agent'], - ipAddress: req.ip || req.socket.remoteAddress, -}); - -/** - * POST /auth/register - * - * Register a new user with email/password - */ -export const register = async (req: Request, res: Response, next: NextFunction) => { - try { - const { email, password, firstName, lastName, acceptTerms } = req.body; - const { userAgent, ipAddress } = getClientInfo(req); - - const result = await emailService.register( - { email, password, firstName, lastName, acceptTerms }, - userAgent, - ipAddress, - ); - - res.status(201).json({ - success: true, - message: result.message, - data: { userId: result.userId }, - }); - } catch (error) { - next(error); - } -}; - -/** - * POST /auth/login - * - * Login with email/password (supports 2FA) - */ -export const login = async (req: Request, res: Response, next: NextFunction) => { - try { - const { email, password, totpCode, rememberMe } = req.body; - const { userAgent, ipAddress } = getClientInfo(req); - - const result = await emailService.login( - { email, password, totpCode, rememberMe }, - userAgent, - ipAddress, - ); - - if (result.requiresTwoFactor) { - return res.status(200).json({ - success: true, - requiresTwoFactor: true, - message: 'Please enter your 2FA code', - }); - } - - res.json({ - success: true, - data: result, - }); - } catch (error) { - next(error); - } -}; - -/** - * POST /auth/verify-email - * - * Verify email address with token - */ -export const verifyEmail = async (req: Request, res: Response, next: NextFunction) => { - try { - const { token } = req.body; - - const result = await emailService.verifyEmail(token); - - res.json({ - success: true, - message: result.message, - }); - } catch (error) { - next(error); - } -}; - -/** - * POST /auth/forgot-password - * - * Request password reset email - */ -export const forgotPassword = async (req: Request, res: Response, next: NextFunction) => { - try { - const { email } = req.body; - - const result = await emailService.sendPasswordResetEmail(email); - - res.json({ - success: true, - message: result.message, - }); - } catch (error) { - next(error); - } -}; - -/** - * POST /auth/reset-password - * - * Reset password using token from email - */ -export const resetPassword = async (req: Request, res: Response, next: NextFunction) => { - try { - const { token, password } = req.body; - - const result = await emailService.resetPassword(token, password); - - res.json({ - success: true, - message: result.message, - }); - } catch (error) { - next(error); - } -}; - -/** - * POST /auth/change-password - * - * Change password for authenticated user - */ -export const changePassword = async (req: Request, res: Response, next: NextFunction) => { - try { - const userId = req.user!.id; - const { currentPassword, newPassword } = req.body; - - const result = await emailService.changePassword(userId, currentPassword, newPassword); - - res.json({ - success: true, - message: result.message, - }); - } catch (error) { - next(error); - } -}; diff --git a/apps/backend/src/modules/auth/controllers/index.ts b/apps/backend/src/modules/auth/controllers/index.ts deleted file mode 100644 index cdb1dcf..0000000 --- a/apps/backend/src/modules/auth/controllers/index.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Auth Controllers - * - * @description Export all auth controllers. - * Result of P0-009: Auth Controller split. - * - * Original auth.controller.ts (571 LOC) divided into: - * - email-auth.controller.ts: Email/password authentication - * - oauth.controller.ts: OAuth providers (Google, Facebook, Twitter, Apple, GitHub) - * - phone-auth.controller.ts: Phone OTP authentication - * - two-factor.controller.ts: 2FA/TOTP operations - * - token.controller.ts: Token and session management - */ - -// Email/Password Authentication -export { - register, - login, - verifyEmail, - forgotPassword, - resetPassword, - changePassword, -} from './email-auth.controller'; - -// OAuth Authentication -export { - getOAuthUrl, - handleOAuthCallback, - verifyOAuthToken, - getLinkedAccounts, - unlinkAccount, -} from './oauth.controller'; - -// Phone Authentication -export { - sendPhoneOTP, - verifyPhoneOTP, -} from './phone-auth.controller'; - -// Two-Factor Authentication -export { - setup2FA, - enable2FA, - disable2FA, - regenerateBackupCodes, - get2FAStatus, -} from './two-factor.controller'; - -// Token/Session Management -export { - refreshToken, - logout, - logoutAll, - getSessions, - revokeSession, - getCurrentUser, -} from './token.controller'; diff --git a/apps/backend/src/modules/auth/controllers/oauth.controller.ts b/apps/backend/src/modules/auth/controllers/oauth.controller.ts deleted file mode 100644 index 2eb16fb..0000000 --- a/apps/backend/src/modules/auth/controllers/oauth.controller.ts +++ /dev/null @@ -1,248 +0,0 @@ -/** - * OAuthController - * - * @description Controller for OAuth authentication (Google, Facebook, Twitter, Apple, GitHub). - * Extracted from auth.controller.ts (P0-009: Auth Controller split). - * - * Routes: - * - GET /auth/oauth/:provider - Get OAuth authorization URL - * - GET /auth/callback/:provider - Handle OAuth callback - * - POST /auth/oauth/:provider/verify - Verify OAuth token (mobile/SPA) - * - GET /auth/accounts - Get linked OAuth accounts - * - DELETE /auth/accounts/:provider - Unlink OAuth account - * - * @see EmailAuthController - Email/password authentication - * @see TwoFactorController - 2FA operations - * @see oauthStateStore - Redis-based state storage (P0-010) - */ -import { Request, Response, NextFunction } from 'express'; -import { oauthService } from '../services/oauth.service'; -import { oauthStateStore } from '../stores/oauth-state.store'; -import { config } from '../../../config'; -import { logger } from '../../../shared/utils/logger'; -import type { AuthProvider } from '../types/auth.types'; - -/** - * Gets client info from request - */ -const getClientInfo = (req: Request) => ({ - userAgent: req.headers['user-agent'], - ipAddress: req.ip || req.socket.remoteAddress, -}); - -/** - * GET /auth/oauth/:provider - * - * Get OAuth authorization URL for provider - */ -export const getOAuthUrl = async (req: Request, res: Response, next: NextFunction) => { - try { - const provider = req.params.provider as AuthProvider; - const { returnUrl } = req.query; - - const state = oauthService.generateState(); - let codeVerifier: string | undefined; - let authUrl: string; - - switch (provider) { - case 'google': - authUrl = oauthService.getGoogleAuthUrl(state); - break; - - case 'facebook': - authUrl = oauthService.getFacebookAuthUrl(state); - break; - - case 'twitter': { - codeVerifier = oauthService.generateCodeVerifier(); - const codeChallenge = oauthService.generateCodeChallenge(codeVerifier); - authUrl = oauthService.getTwitterAuthUrl(state, codeChallenge); - break; - } - - case 'apple': - authUrl = oauthService.getAppleAuthUrl(state); - break; - - case 'github': - authUrl = oauthService.getGitHubAuthUrl(state); - break; - - default: - return res.status(400).json({ - success: false, - error: 'Invalid OAuth provider', - }); - } - - // Store state in Redis (P0-010: OAuth state → Redis) - await oauthStateStore.set(state, { - provider, - codeVerifier, - returnUrl: returnUrl as string, - }); - - res.json({ - success: true, - data: { authUrl }, - }); - } catch (error) { - next(error); - } -}; - -/** - * GET /auth/callback/:provider - * - * Handle OAuth callback from provider - */ -export const handleOAuthCallback = async (req: Request, res: Response, _next: NextFunction) => { - try { - const provider = req.params.provider as AuthProvider; - const { code, state } = req.query; - const { userAgent, ipAddress } = getClientInfo(req); - - // Verify and retrieve state from Redis (P0-010) - const stateData = await oauthStateStore.getAndDelete(state as string); - - if (!stateData) { - return res.redirect(`${config.app.frontendUrl}/login?error=invalid_state`); - } - - let oauthData; - - switch (provider) { - case 'google': - oauthData = await oauthService.verifyGoogleToken(code as string); - break; - - case 'facebook': - oauthData = await oauthService.verifyFacebookToken(code as string); - break; - - case 'twitter': - if (!stateData.codeVerifier) { - return res.redirect(`${config.app.frontendUrl}/login?error=missing_code_verifier`); - } - oauthData = await oauthService.verifyTwitterToken(code as string, stateData.codeVerifier); - break; - - case 'apple': - oauthData = await oauthService.verifyAppleToken(code as string, req.query.id_token as string); - break; - - case 'github': - oauthData = await oauthService.verifyGitHubToken(code as string); - break; - - default: - return res.redirect(`${config.app.frontendUrl}/login?error=invalid_provider`); - } - - if (!oauthData) { - return res.redirect(`${config.app.frontendUrl}/login?error=oauth_failed`); - } - - // Handle OAuth login/registration - const result = await oauthService.handleOAuthCallback(oauthData, userAgent, ipAddress); - - // Redirect with tokens - const params = new URLSearchParams({ - accessToken: result.tokens.accessToken, - refreshToken: result.tokens.refreshToken, - isNewUser: result.isNewUser?.toString() || 'false', - }); - - const returnUrl = stateData.returnUrl || '/dashboard'; - res.redirect(`${config.app.frontendUrl}/auth/callback?${params}&returnUrl=${encodeURIComponent(returnUrl)}`); - } catch (error) { - logger.error('OAuth callback error', { error }); - res.redirect(`${config.app.frontendUrl}/login?error=oauth_error`); - } -}; - -/** - * POST /auth/oauth/:provider/verify - * - * Verify OAuth token directly (for mobile/SPA) - */ -export const verifyOAuthToken = async (req: Request, res: Response, next: NextFunction) => { - try { - const provider = req.params.provider as AuthProvider; - const { token } = req.body; - const { userAgent, ipAddress } = getClientInfo(req); - - let oauthData; - - switch (provider) { - case 'google': - // For mobile, we receive an ID token directly - oauthData = await oauthService.verifyGoogleIdToken(token); - break; - - // Other providers would need their mobile SDKs - default: - return res.status(400).json({ - success: false, - error: 'Provider not supported for direct token verification', - }); - } - - if (!oauthData) { - return res.status(401).json({ - success: false, - error: 'Invalid OAuth token', - }); - } - - const result = await oauthService.handleOAuthCallback(oauthData, userAgent, ipAddress); - - res.json({ - success: true, - data: result, - }); - } catch (error) { - next(error); - } -}; - -/** - * GET /auth/accounts - * - * Get all linked OAuth accounts for authenticated user - */ -export const getLinkedAccounts = async (req: Request, res: Response, next: NextFunction) => { - try { - const userId = req.user!.id; - - const accounts = await oauthService.getLinkedAccounts(userId); - - res.json({ - success: true, - data: accounts, - }); - } catch (error) { - next(error); - } -}; - -/** - * DELETE /auth/accounts/:provider - * - * Unlink an OAuth account from user profile - */ -export const unlinkAccount = async (req: Request, res: Response, next: NextFunction) => { - try { - const userId = req.user!.id; - const provider = req.params.provider as AuthProvider; - - await oauthService.unlinkOAuthAccount(userId, provider); - - res.json({ - success: true, - message: `${provider} account unlinked`, - }); - } catch (error) { - next(error); - } -}; diff --git a/apps/backend/src/modules/auth/controllers/phone-auth.controller.ts b/apps/backend/src/modules/auth/controllers/phone-auth.controller.ts deleted file mode 100644 index b80cca6..0000000 --- a/apps/backend/src/modules/auth/controllers/phone-auth.controller.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * PhoneAuthController - * - * @description Controller for phone-based authentication (SMS/WhatsApp OTP). - * Extracted from auth.controller.ts (P0-009: Auth Controller split). - * - * Routes: - * - POST /auth/phone/send-otp - Send OTP via SMS or WhatsApp - * - POST /auth/phone/verify - Verify phone OTP and authenticate - * - * @see EmailAuthController - Email/password authentication - * @see OAuthController - OAuth authentication - */ -import { Request, Response, NextFunction } from 'express'; -import { phoneService } from '../services/phone.service'; - -/** - * Gets client info from request - */ -const getClientInfo = (req: Request) => ({ - userAgent: req.headers['user-agent'], - ipAddress: req.ip || req.socket.remoteAddress, -}); - -/** - * POST /auth/phone/send-otp - * - * Send OTP to phone number via SMS or WhatsApp - */ -export const sendPhoneOTP = async (req: Request, res: Response, next: NextFunction) => { - try { - const { phoneNumber, countryCode, channel } = req.body; - - const result = await phoneService.sendOTP(phoneNumber, countryCode, channel); - - res.json({ - success: true, - message: result.message, - data: { expiresAt: result.expiresAt }, - }); - } catch (error) { - next(error); - } -}; - -/** - * POST /auth/phone/verify - * - * Verify phone OTP and authenticate user - */ -export const verifyPhoneOTP = async (req: Request, res: Response, next: NextFunction) => { - try { - const { phoneNumber, countryCode, otpCode } = req.body; - const { userAgent, ipAddress } = getClientInfo(req); - - const result = await phoneService.verifyOTP( - phoneNumber, - countryCode, - otpCode, - userAgent, - ipAddress, - ); - - res.json({ - success: true, - data: result, - }); - } catch (error) { - next(error); - } -}; diff --git a/apps/backend/src/modules/auth/controllers/token.controller.ts b/apps/backend/src/modules/auth/controllers/token.controller.ts deleted file mode 100644 index 12cb662..0000000 --- a/apps/backend/src/modules/auth/controllers/token.controller.ts +++ /dev/null @@ -1,162 +0,0 @@ -/** - * TokenController - * - * @description Controller for token and session management. - * Extracted from auth.controller.ts (P0-009: Auth Controller split). - * - * Routes: - * - POST /auth/refresh - Refresh access token - * - POST /auth/logout - Logout current session - * - POST /auth/logout/all - Logout all sessions - * - GET /auth/sessions - Get active sessions - * - DELETE /auth/sessions/:sessionId - Revoke specific session - * - GET /auth/me - Get current user info - * - * @see EmailAuthController - Email/password authentication - * @see OAuthController - OAuth authentication - */ -import { Request, Response, NextFunction } from 'express'; -import { tokenService } from '../services/token.service'; - -/** - * POST /auth/refresh - * - * Refresh access token using refresh token - */ -export const refreshToken = async (req: Request, res: Response, next: NextFunction) => { - try { - const { refreshToken } = req.body; - - const tokens = await tokenService.refreshSession(refreshToken); - - if (!tokens) { - return res.status(401).json({ - success: false, - error: 'Invalid or expired refresh token', - }); - } - - res.json({ - success: true, - data: tokens, - }); - } catch (error) { - next(error); - } -}; - -/** - * POST /auth/logout - * - * Logout current session - */ -export const logout = async (req: Request, res: Response, next: NextFunction) => { - try { - const userId = req.user!.id; - const sessionId = req.sessionId; - - if (sessionId) { - await tokenService.revokeSession(sessionId, userId); - } - - res.json({ - success: true, - message: 'Logged out successfully', - }); - } catch (error) { - next(error); - } -}; - -/** - * POST /auth/logout/all - * - * Logout from all sessions - */ -export const logoutAll = async (req: Request, res: Response, next: NextFunction) => { - try { - const userId = req.user!.id; - - const count = await tokenService.revokeAllUserSessions(userId); - - res.json({ - success: true, - message: `Logged out from ${count} sessions`, - }); - } catch (error) { - next(error); - } -}; - -/** - * GET /auth/sessions - * - * Get all active sessions for authenticated user - */ -export const getSessions = async (req: Request, res: Response, next: NextFunction) => { - try { - const userId = req.user!.id; - - const sessions = await tokenService.getActiveSessions(userId); - - res.json({ - success: true, - data: sessions.map((s) => ({ - id: s.id, - userAgent: s.userAgent, - ipAddress: s.ipAddress, - createdAt: s.createdAt, - lastActiveAt: s.lastActiveAt, - isCurrent: s.id === req.sessionId, - })), - }); - } catch (error) { - next(error); - } -}; - -/** - * DELETE /auth/sessions/:sessionId - * - * Revoke a specific session - */ -export const revokeSession = async (req: Request, res: Response, next: NextFunction) => { - try { - const userId = req.user!.id; - const { sessionId } = req.params; - - const revoked = await tokenService.revokeSession(sessionId, userId); - - if (!revoked) { - return res.status(404).json({ - success: false, - error: 'Session not found', - }); - } - - res.json({ - success: true, - message: 'Session revoked', - }); - } catch (error) { - next(error); - } -}; - -/** - * GET /auth/me - * - * Get current authenticated user information - */ -export const getCurrentUser = async (req: Request, res: Response, next: NextFunction) => { - try { - res.json({ - success: true, - data: { - user: req.user, - }, - }); - } catch (error) { - next(error); - } -}; diff --git a/apps/backend/src/modules/auth/controllers/two-factor.controller.ts b/apps/backend/src/modules/auth/controllers/two-factor.controller.ts deleted file mode 100644 index ff107c5..0000000 --- a/apps/backend/src/modules/auth/controllers/two-factor.controller.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * TwoFactorController - * - * @description Controller for Two-Factor Authentication (2FA/TOTP). - * Extracted from auth.controller.ts (P0-009: Auth Controller split). - * - * Routes: - * - POST /auth/2fa/setup - Generate TOTP secret and QR code - * - POST /auth/2fa/enable - Enable 2FA with verification code - * - POST /auth/2fa/disable - Disable 2FA with verification code - * - POST /auth/2fa/backup-codes - Regenerate backup codes - * - * @see EmailAuthController - Email/password authentication (handles 2FA during login) - * @see TokenController - Token management - */ -import { Request, Response, NextFunction } from 'express'; -import { twoFactorService } from '../services/twofa.service'; - -/** - * POST /auth/2fa/setup - * - * Generate TOTP secret and QR code for 2FA setup - */ -export const setup2FA = async (req: Request, res: Response, next: NextFunction) => { - try { - const userId = req.user!.id; - - const result = await twoFactorService.setupTOTP(userId); - - res.json({ - success: true, - data: result, - }); - } catch (error) { - next(error); - } -}; - -/** - * POST /auth/2fa/enable - * - * Enable 2FA after verifying the setup code - */ -export const enable2FA = async (req: Request, res: Response, next: NextFunction) => { - try { - const userId = req.user!.id; - const { code } = req.body; - - const result = await twoFactorService.enableTOTP(userId, code); - - res.json({ - success: true, - message: result.message, - }); - } catch (error) { - next(error); - } -}; - -/** - * POST /auth/2fa/disable - * - * Disable 2FA with verification code - */ -export const disable2FA = async (req: Request, res: Response, next: NextFunction) => { - try { - const userId = req.user!.id; - const { code } = req.body; - - const result = await twoFactorService.disableTOTP(userId, code); - - res.json({ - success: true, - message: result.message, - }); - } catch (error) { - next(error); - } -}; - -/** - * POST /auth/2fa/backup-codes - * - * Regenerate backup codes (requires 2FA verification) - */ -export const regenerateBackupCodes = async (req: Request, res: Response, next: NextFunction) => { - try { - const userId = req.user!.id; - const { code } = req.body; - - const result = await twoFactorService.regenerateBackupCodes(userId, code); - - res.json({ - success: true, - data: result, - }); - } catch (error) { - next(error); - } -}; - -/** - * GET /auth/2fa/status - * - * Get 2FA status for authenticated user - */ -export const get2FAStatus = async (req: Request, res: Response, next: NextFunction) => { - try { - const userId = req.user!.id; - - const status = await twoFactorService.getTOTPStatus(userId); - - res.json({ - success: true, - data: { - enabled: status.enabled, - method: status.method, - backupCodesRemaining: status.backupCodesRemaining, - }, - }); - } catch (error) { - next(error); - } -}; diff --git a/apps/backend/src/modules/auth/dto/change-password.dto.ts b/apps/backend/src/modules/auth/dto/change-password.dto.ts deleted file mode 100644 index 8cba9ba..0000000 --- a/apps/backend/src/modules/auth/dto/change-password.dto.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Change Password DTO - Input validation for password changes - */ - -import { IsString, MinLength, MaxLength, Matches, IsNotEmpty } from 'class-validator'; - -export class ChangePasswordDto { - @IsString() - @IsNotEmpty({ message: 'Current password is required' }) - currentPassword: string; - - @IsString() - @MinLength(8, { message: 'New password must be at least 8 characters long' }) - @MaxLength(128, { message: 'New password cannot exceed 128 characters' }) - @Matches( - /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/, - { message: 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character' } - ) - newPassword: string; -} - -export class ResetPasswordDto { - @IsString() - @IsNotEmpty({ message: 'Reset token is required' }) - token: string; - - @IsString() - @MinLength(8, { message: 'Password must be at least 8 characters long' }) - @MaxLength(128) - @Matches( - /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/, - { message: 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character' } - ) - newPassword: string; -} - -export class ForgotPasswordDto { - @IsString() - @IsNotEmpty({ message: 'Email is required' }) - email: string; -} diff --git a/apps/backend/src/modules/auth/dto/index.ts b/apps/backend/src/modules/auth/dto/index.ts deleted file mode 100644 index 4ff6b05..0000000 --- a/apps/backend/src/modules/auth/dto/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Auth DTOs - Export all validation DTOs - */ - -export { RegisterDto } from './register.dto'; -export { LoginDto } from './login.dto'; -export { RefreshTokenDto } from './refresh-token.dto'; -export { - ChangePasswordDto, - ResetPasswordDto, - ForgotPasswordDto, -} from './change-password.dto'; -export { - OAuthInitiateDto, - OAuthCallbackDto, - type OAuthProvider, -} from './oauth.dto'; diff --git a/apps/backend/src/modules/auth/dto/login.dto.ts b/apps/backend/src/modules/auth/dto/login.dto.ts deleted file mode 100644 index f844779..0000000 --- a/apps/backend/src/modules/auth/dto/login.dto.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Login DTO - Input validation for user login - * - * @usage - * ```typescript - * router.post('/login', validateDto(LoginDto), authController.login); - * ``` - */ - -import { IsEmail, IsString, MinLength, MaxLength, IsOptional, IsBoolean, Length } from 'class-validator'; - -export class LoginDto { - @IsEmail({}, { message: 'Please provide a valid email address' }) - email: string; - - @IsString() - @MinLength(1, { message: 'Password is required' }) - @MaxLength(128) - password: string; - - @IsString() - @Length(6, 6, { message: 'TOTP code must be exactly 6 digits' }) - @IsOptional() - totpCode?: string; - - @IsBoolean() - @IsOptional() - rememberMe?: boolean; -} diff --git a/apps/backend/src/modules/auth/dto/oauth.dto.ts b/apps/backend/src/modules/auth/dto/oauth.dto.ts deleted file mode 100644 index af6c56e..0000000 --- a/apps/backend/src/modules/auth/dto/oauth.dto.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * OAuth DTOs - Input validation for OAuth flows - */ - -import { IsString, IsNotEmpty, IsIn, IsOptional } from 'class-validator'; - -const SUPPORTED_PROVIDERS = ['google', 'github', 'apple'] as const; -export type OAuthProvider = typeof SUPPORTED_PROVIDERS[number]; - -export class OAuthInitiateDto { - @IsString() - @IsIn(SUPPORTED_PROVIDERS, { message: 'Unsupported OAuth provider' }) - provider: OAuthProvider; - - @IsString() - @IsOptional() - redirectUri?: string; -} - -export class OAuthCallbackDto { - @IsString() - @IsNotEmpty({ message: 'Authorization code is required' }) - code: string; - - @IsString() - @IsNotEmpty({ message: 'State parameter is required' }) - state: string; - - @IsString() - @IsOptional() - error?: string; - - @IsString() - @IsOptional() - error_description?: string; -} diff --git a/apps/backend/src/modules/auth/dto/refresh-token.dto.ts b/apps/backend/src/modules/auth/dto/refresh-token.dto.ts deleted file mode 100644 index f957ea3..0000000 --- a/apps/backend/src/modules/auth/dto/refresh-token.dto.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Refresh Token DTO - Input validation for token refresh - */ - -import { IsString, IsNotEmpty } from 'class-validator'; - -export class RefreshTokenDto { - @IsString() - @IsNotEmpty({ message: 'Refresh token is required' }) - refreshToken: string; -} diff --git a/apps/backend/src/modules/auth/dto/register.dto.ts b/apps/backend/src/modules/auth/dto/register.dto.ts deleted file mode 100644 index b126826..0000000 --- a/apps/backend/src/modules/auth/dto/register.dto.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Register DTO - Input validation for user registration - * - * @usage - * ```typescript - * router.post('/register', validateDto(RegisterDto), authController.register); - * ``` - */ - -import { IsEmail, IsString, MinLength, MaxLength, IsBoolean, IsOptional, Matches } from 'class-validator'; - -export class RegisterDto { - @IsEmail({}, { message: 'Please provide a valid email address' }) - email: string; - - @IsString() - @MinLength(8, { message: 'Password must be at least 8 characters long' }) - @MaxLength(128, { message: 'Password cannot exceed 128 characters' }) - @Matches( - /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/, - { message: 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character' } - ) - password: string; - - @IsString() - @MinLength(1, { message: 'First name is required' }) - @MaxLength(100) - @IsOptional() - firstName?: string; - - @IsString() - @MaxLength(100) - @IsOptional() - lastName?: string; - - @IsBoolean({ message: 'You must accept the terms and conditions' }) - acceptTerms: boolean; -} diff --git a/apps/backend/src/modules/auth/services/__tests__/email.service.spec.ts b/apps/backend/src/modules/auth/services/__tests__/email.service.spec.ts deleted file mode 100644 index 888c98f..0000000 --- a/apps/backend/src/modules/auth/services/__tests__/email.service.spec.ts +++ /dev/null @@ -1,497 +0,0 @@ -/** - * Email Service Unit Tests - * - * Tests for email authentication service including: - * - User registration - * - User login - * - Email verification - * - Password reset flows - */ - -import type { User, Profile } from '../../types/auth.types'; -import { mockDb, createMockQueryResult, createMockPoolClient, resetDatabaseMocks } from '../../../../__tests__/mocks/database.mock'; -import { sentEmails, resetEmailMocks, findEmailByRecipient } from '../../../../__tests__/mocks/email.mock'; - -// Mock database -jest.mock('../../../../shared/database', () => ({ - db: mockDb, -})); - -// Import service after mocks are set up -import { EmailService } from '../email.service'; - -// Mock dependencies -jest.mock('../token.service', () => ({ - tokenService: { - generateEmailToken: jest.fn(() => 'mock-email-token-123'), - hashToken: jest.fn((token: string) => `hashed-${token}`), - createSession: jest.fn(() => ({ - session: { - id: 'session-123', - userId: 'user-123', - refreshToken: 'refresh-token-123', - expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), - createdAt: new Date(), - lastActiveAt: new Date(), - }, - tokens: { - accessToken: 'access-token-123', - refreshToken: 'refresh-token-123', - expiresIn: 900, - tokenType: 'Bearer' as const, - }, - })), - revokeAllUserSessions: jest.fn(() => Promise.resolve(2)), - }, -})); - -jest.mock('../twofa.service', () => ({ - twoFactorService: { - verifyTOTP: jest.fn(() => Promise.resolve(true)), - }, -})); - -jest.mock('bcryptjs', () => ({ - hash: jest.fn((password: string) => Promise.resolve(`hashed-${password}`)), - compare: jest.fn((password: string, hash: string) => { - return Promise.resolve(hash === `hashed-${password}`); - }), -})); - -describe('EmailService', () => { - let emailService: EmailService; - - beforeEach(() => { - resetDatabaseMocks(); - resetEmailMocks(); - emailService = new EmailService(); - }); - - describe('register', () => { - const validRegistrationData = { - email: 'newuser@example.com', - password: 'StrongPass123!', - firstName: 'John', - lastName: 'Doe', - acceptTerms: true, - }; - - it('should successfully register a new user', async () => { - // Mock: Check if user exists (should not exist) - mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); - - // Mock: Get pool client for transaction - const mockClient = createMockPoolClient(); - mockDb.getClient.mockResolvedValueOnce(mockClient); - - // Mock: Create user - const mockUser: User = { - id: 'user-123', - email: 'newuser@example.com', - emailVerified: false, - phoneVerified: false, - primaryAuthProvider: 'email', - totpEnabled: false, - role: 'investor', - status: 'pending', - failedLoginAttempts: 0, - createdAt: new Date(), - updatedAt: new Date(), - }; - - mockClient.query - .mockResolvedValueOnce({ command: 'BEGIN', rowCount: 0, rows: [], oid: 0, fields: [] }) - .mockResolvedValueOnce(createMockQueryResult([mockUser])) - .mockResolvedValueOnce(createMockQueryResult([])) - .mockResolvedValueOnce(createMockQueryResult([])) - .mockResolvedValueOnce({ command: 'COMMIT', rowCount: 0, rows: [], oid: 0, fields: [] }); - - // Mock: Store verification token - mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); - - // Mock: Log auth event - mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); - - const result = await emailService.register(validRegistrationData, 'Mozilla/5.0', '127.0.0.1'); - - expect(result).toEqual({ - userId: 'user-123', - message: 'Registration successful. Please check your email to verify your account.', - }); - - // Verify email was sent - expect(sentEmails).toHaveLength(1); - const verificationEmail = findEmailByRecipient('newuser@example.com'); - expect(verificationEmail).toBeDefined(); - expect(verificationEmail?.subject).toContain('Verifica tu cuenta'); - }); - - it('should reject registration if email already exists', async () => { - // Mock: User exists - mockDb.query.mockResolvedValueOnce( - createMockQueryResult([{ id: 'existing-user-123' }]) - ); - - await expect( - emailService.register(validRegistrationData, 'Mozilla/5.0', '127.0.0.1') - ).rejects.toThrow('Email already registered'); - }); - - it('should reject registration if terms not accepted', async () => { - const invalidData = { ...validRegistrationData, acceptTerms: false }; - - await expect( - emailService.register(invalidData, 'Mozilla/5.0', '127.0.0.1') - ).rejects.toThrow('You must accept the terms and conditions'); - }); - - it('should reject weak passwords', async () => { - // Mock: User does not exist - mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); - - const weakPasswordData = { ...validRegistrationData, password: 'weak' }; - - await expect( - emailService.register(weakPasswordData, 'Mozilla/5.0', '127.0.0.1') - ).rejects.toThrow('Password must be at least 8 characters long'); - }); - - it('should rollback transaction on error', async () => { - // Mock: Check if user exists (should not exist) - mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); - - // Mock: Get pool client for transaction - const mockClient = createMockPoolClient(); - mockDb.getClient.mockResolvedValueOnce(mockClient); - - // Mock transaction failure - mockClient.query - .mockResolvedValueOnce({ command: 'BEGIN', rowCount: 0, rows: [], oid: 0, fields: [] }) - .mockRejectedValueOnce(new Error('Database error')); - - await expect( - emailService.register(validRegistrationData, 'Mozilla/5.0', '127.0.0.1') - ).rejects.toThrow('Database error'); - - expect(mockClient.query).toHaveBeenCalledWith('ROLLBACK'); - expect(mockClient.release).toHaveBeenCalled(); - }); - }); - - describe('login', () => { - const loginData = { - email: 'user@example.com', - password: 'StrongPass123!', - }; - - const mockUser: User = { - id: 'user-123', - email: 'user@example.com', - emailVerified: true, - phoneVerified: false, - encryptedPassword: 'hashed-StrongPass123!', - primaryAuthProvider: 'email', - totpEnabled: false, - role: 'investor', - status: 'active', - failedLoginAttempts: 0, - createdAt: new Date(), - updatedAt: new Date(), - }; - - const mockProfile: Profile = { - id: 'profile-123', - userId: 'user-123', - firstName: 'John', - lastName: 'Doe', - displayName: 'John Doe', - timezone: 'UTC', - language: 'en', - preferredCurrency: 'USD', - }; - - it('should successfully login with valid credentials', async () => { - // Mock: Get user - mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockUser])); - - // Mock: Reset failed attempts - mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); - - // Mock: Get profile - mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockProfile])); - - // Mock: Log success - mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); - - const result = await emailService.login(loginData, 'Mozilla/5.0', '127.0.0.1'); - - expect(result.user).toBeDefined(); - expect(result.user.id).toBe('user-123'); - expect(result.user).not.toHaveProperty('encryptedPassword'); - expect(result.profile).toEqual(mockProfile); - expect(result.tokens).toBeDefined(); - expect(result.tokens.accessToken).toBe('access-token-123'); - }); - - it('should reject login with invalid email', async () => { - // Mock: User not found - mockDb.query - .mockResolvedValueOnce(createMockQueryResult([])) - .mockResolvedValueOnce(createMockQueryResult([])); // Log failed login - - await expect( - emailService.login(loginData, 'Mozilla/5.0', '127.0.0.1') - ).rejects.toThrow('Invalid email or password'); - }); - - it('should reject login with invalid password', async () => { - // Mock: Get user - mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockUser])); - - // Mock: Increment failed attempts - mockDb.query.mockResolvedValueOnce( - createMockQueryResult([{ failed_login_attempts: 1 }]) - ); - - // Mock: Log failed login - mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); - - const invalidLogin = { ...loginData, password: 'WrongPassword123!' }; - - await expect( - emailService.login(invalidLogin, 'Mozilla/5.0', '127.0.0.1') - ).rejects.toThrow('Invalid email or password'); - }); - - it('should reject login if email not verified', async () => { - const unverifiedUser = { ...mockUser, emailVerified: false, status: 'pending' as const }; - - // Mock: Get user - mockDb.query.mockResolvedValueOnce(createMockQueryResult([unverifiedUser])); - - await expect( - emailService.login(loginData, 'Mozilla/5.0', '127.0.0.1') - ).rejects.toThrow('Please verify your email before logging in'); - }); - - it('should reject login if account is banned', async () => { - const bannedUser = { ...mockUser, status: 'banned' as const }; - - // Mock: Get user - mockDb.query.mockResolvedValueOnce(createMockQueryResult([bannedUser])); - - await expect( - emailService.login(loginData, 'Mozilla/5.0', '127.0.0.1') - ).rejects.toThrow('Account has been suspended'); - }); - - it('should reject login if account is locked', async () => { - const lockedUser = { - ...mockUser, - lockedUntil: new Date(Date.now() + 60 * 60 * 1000), - }; - - // Mock: Get user - mockDb.query.mockResolvedValueOnce(createMockQueryResult([lockedUser])); - - await expect( - emailService.login(loginData, 'Mozilla/5.0', '127.0.0.1') - ).rejects.toThrow('Account is temporarily locked'); - }); - - it('should require 2FA code when enabled', async () => { - const user2FA = { ...mockUser, totpEnabled: true }; - - // Mock: Get user - mockDb.query.mockResolvedValueOnce(createMockQueryResult([user2FA])); - - const result = await emailService.login(loginData, 'Mozilla/5.0', '127.0.0.1'); - - expect(result.requiresTwoFactor).toBe(true); - expect(result.tokens.accessToken).toBe(''); - }); - }); - - describe('verifyEmail', () => { - it('should successfully verify email with valid token', async () => { - const mockVerification = { - id: 'verification-123', - email: 'user@example.com', - token: 'mock-email-token-123', - tokenHash: 'hashed-mock-email-token-123', - userId: 'user-123', - purpose: 'verify', - used: false, - expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), - createdAt: new Date(), - }; - - // Mock: Get verification - mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockVerification])); - - // Mock: Mark token as used - mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); - - // Mock: Activate user - mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); - - // Mock: Log event - mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); - - const result = await emailService.verifyEmail('mock-email-token-123'); - - expect(result).toEqual({ - success: true, - message: 'Email verified successfully. You can now log in.', - }); - }); - - it('should reject invalid verification token', async () => { - // Mock: Token not found - mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); - - await expect(emailService.verifyEmail('invalid-token')).rejects.toThrow( - 'Invalid or expired verification link' - ); - }); - }); - - describe('sendPasswordResetEmail', () => { - it('should send password reset email for existing user', async () => { - // Mock: User exists - mockDb.query.mockResolvedValueOnce(createMockQueryResult([{ id: 'user-123' }])); - - // Mock: Store reset token - mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); - - // Mock: Log event - mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); - - const result = await emailService.sendPasswordResetEmail('user@example.com'); - - expect(result.message).toContain('If an account exists with this email'); - expect(sentEmails).toHaveLength(1); - - const resetEmail = findEmailByRecipient('user@example.com'); - expect(resetEmail).toBeDefined(); - expect(resetEmail?.subject).toContain('Restablece tu contraseña'); - }); - - it('should not reveal if user does not exist', async () => { - // Mock: User not found - mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); - - const result = await emailService.sendPasswordResetEmail('nonexistent@example.com'); - - expect(result.message).toContain('If an account exists with this email'); - expect(sentEmails).toHaveLength(0); - }); - }); - - describe('resetPassword', () => { - it('should successfully reset password with valid token', async () => { - const mockVerification = { - id: 'verification-123', - email: 'user@example.com', - token: 'reset-token-123', - tokenHash: 'hashed-reset-token-123', - userId: 'user-123', - purpose: 'reset_password', - used: false, - expiresAt: new Date(Date.now() + 60 * 60 * 1000), - createdAt: new Date(), - }; - - // Mock: Get verification - mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockVerification])); - - // Mock: Update password - mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); - - // Mock: Mark token as used - mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); - - // Mock: Log event - mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); - - const result = await emailService.resetPassword('reset-token-123', 'NewStrongPass123!'); - - expect(result.message).toContain('Password reset successfully'); - }); - - it('should reject weak new password', async () => { - const mockVerification = { - id: 'verification-123', - email: 'user@example.com', - userId: 'user-123', - purpose: 'reset_password', - used: false, - expiresAt: new Date(Date.now() + 60 * 60 * 1000), - }; - - // Mock: Get verification - mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockVerification])); - - await expect(emailService.resetPassword('reset-token-123', 'weak')).rejects.toThrow( - 'Password must be at least 8 characters long' - ); - }); - }); - - describe('changePassword', () => { - it('should successfully change password with valid current password', async () => { - const mockUser: User = { - id: 'user-123', - email: 'user@example.com', - emailVerified: true, - phoneVerified: false, - encryptedPassword: 'hashed-OldPass123!', - primaryAuthProvider: 'email', - totpEnabled: false, - role: 'investor', - status: 'active', - failedLoginAttempts: 0, - createdAt: new Date(), - updatedAt: new Date(), - }; - - // Mock: Get user - mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockUser])); - - // Mock: Update password - mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); - - const result = await emailService.changePassword( - 'user-123', - 'OldPass123!', - 'NewStrongPass123!' - ); - - expect(result.message).toBe('Password changed successfully'); - }); - - it('should reject incorrect current password', async () => { - const mockUser: User = { - id: 'user-123', - email: 'user@example.com', - emailVerified: true, - phoneVerified: false, - encryptedPassword: 'hashed-OldPass123!', - primaryAuthProvider: 'email', - totpEnabled: false, - role: 'investor', - status: 'active', - failedLoginAttempts: 0, - createdAt: new Date(), - updatedAt: new Date(), - }; - - // Mock: Get user - mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockUser])); - - await expect( - emailService.changePassword('user-123', 'WrongPass123!', 'NewStrongPass123!') - ).rejects.toThrow('Current password is incorrect'); - }); - }); -}); diff --git a/apps/backend/src/modules/auth/services/__tests__/token.service.spec.ts b/apps/backend/src/modules/auth/services/__tests__/token.service.spec.ts deleted file mode 100644 index 8351189..0000000 --- a/apps/backend/src/modules/auth/services/__tests__/token.service.spec.ts +++ /dev/null @@ -1,489 +0,0 @@ -/** - * Token Service Unit Tests - * - * Tests for token management including: - * - JWT token generation - * - Token verification - * - Session management - * - Token refresh - * - Session revocation - */ - -import jwt from 'jsonwebtoken'; -import type { User, Session, JWTPayload, JWTRefreshPayload } from '../../types/auth.types'; -import { mockDb, createMockQueryResult, resetDatabaseMocks } from '../../../../__tests__/mocks/database.mock'; - -// Mock database -jest.mock('../../../../shared/database', () => ({ - db: mockDb, -})); - -// Mock config -jest.mock('../../../../config', () => ({ - config: { - jwt: { - accessSecret: 'test-access-secret', - refreshSecret: 'test-refresh-secret', - accessExpiry: '15m', - refreshExpiry: '7d', - }, - }, -})); - -// Import service after mocks -import { TokenService } from '../token.service'; - -describe('TokenService', () => { - let tokenService: TokenService; - - const mockUser: User = { - id: 'user-123', - email: 'user@example.com', - emailVerified: true, - phoneVerified: false, - primaryAuthProvider: 'email', - totpEnabled: false, - role: 'investor', - status: 'active', - failedLoginAttempts: 0, - createdAt: new Date(), - updatedAt: new Date(), - }; - - beforeEach(() => { - resetDatabaseMocks(); - tokenService = new TokenService(); - }); - - describe('generateAccessToken', () => { - it('should generate a valid access token', () => { - const token = tokenService.generateAccessToken(mockUser); - - expect(token).toBeTruthy(); - expect(typeof token).toBe('string'); - - // Verify token structure - const decoded = jwt.verify(token, 'test-access-secret') as JWTPayload; - expect(decoded.sub).toBe('user-123'); - expect(decoded.email).toBe('user@example.com'); - expect(decoded.role).toBe('investor'); - expect(decoded.provider).toBe('email'); - expect(decoded.exp).toBeDefined(); - expect(decoded.iat).toBeDefined(); - }); - - it('should include correct expiry time', () => { - const token = tokenService.generateAccessToken(mockUser); - const decoded = jwt.verify(token, 'test-access-secret') as JWTPayload; - - const now = Math.floor(Date.now() / 1000); - const expectedExpiry = now + 15 * 60; // 15 minutes - - expect(decoded.exp).toBeGreaterThan(now); - expect(decoded.exp).toBeLessThanOrEqual(expectedExpiry + 5); // Allow 5 second buffer - }); - }); - - describe('generateRefreshToken', () => { - it('should generate a valid refresh token', () => { - const token = tokenService.generateRefreshToken('user-123', 'session-123'); - - expect(token).toBeTruthy(); - expect(typeof token).toBe('string'); - - // Verify token structure - const decoded = jwt.verify(token, 'test-refresh-secret') as JWTRefreshPayload; - expect(decoded.sub).toBe('user-123'); - expect(decoded.sessionId).toBe('session-123'); - expect(decoded.exp).toBeDefined(); - expect(decoded.iat).toBeDefined(); - }); - - it('should include correct expiry time for refresh token', () => { - const token = tokenService.generateRefreshToken('user-123', 'session-123'); - const decoded = jwt.verify(token, 'test-refresh-secret') as JWTRefreshPayload; - - const now = Math.floor(Date.now() / 1000); - const expectedExpiry = now + 7 * 24 * 60 * 60; // 7 days - - expect(decoded.exp).toBeGreaterThan(now); - expect(decoded.exp).toBeLessThanOrEqual(expectedExpiry + 5); // Allow 5 second buffer - }); - }); - - describe('verifyAccessToken', () => { - it('should verify a valid access token', () => { - const token = tokenService.generateAccessToken(mockUser); - const payload = tokenService.verifyAccessToken(token); - - expect(payload).toBeTruthy(); - expect(payload?.sub).toBe('user-123'); - expect(payload?.email).toBe('user@example.com'); - }); - - it('should return null for invalid token', () => { - const payload = tokenService.verifyAccessToken('invalid-token'); - expect(payload).toBeNull(); - }); - - it('should return null for expired token', () => { - // Create an expired token - const expiredToken = jwt.sign( - { - sub: 'user-123', - email: 'user@example.com', - role: 'investor', - provider: 'email', - }, - 'test-access-secret', - { expiresIn: '-1h' } // Expired 1 hour ago - ); - - const payload = tokenService.verifyAccessToken(expiredToken); - expect(payload).toBeNull(); - }); - - it('should return null for token with wrong secret', () => { - const wrongToken = jwt.sign( - { - sub: 'user-123', - email: 'user@example.com', - }, - 'wrong-secret', - { expiresIn: '15m' } - ); - - const payload = tokenService.verifyAccessToken(wrongToken); - expect(payload).toBeNull(); - }); - }); - - describe('verifyRefreshToken', () => { - it('should verify a valid refresh token', () => { - const token = tokenService.generateRefreshToken('user-123', 'session-123'); - const payload = tokenService.verifyRefreshToken(token); - - expect(payload).toBeTruthy(); - expect(payload?.sub).toBe('user-123'); - expect(payload?.sessionId).toBe('session-123'); - }); - - it('should return null for invalid refresh token', () => { - const payload = tokenService.verifyRefreshToken('invalid-token'); - expect(payload).toBeNull(); - }); - }); - - describe('createSession', () => { - it('should create a new session with tokens', async () => { - const mockSession: Session = { - id: 'session-123', - userId: 'user-123', - refreshToken: expect.any(String), - userAgent: 'Mozilla/5.0', - ipAddress: '127.0.0.1', - expiresAt: expect.any(Date), - createdAt: expect.any(Date), - lastActiveAt: expect.any(Date), - }; - - // Mock: Insert session - mockDb.query.mockResolvedValueOnce( - createMockQueryResult([{ - id: 'session-123', - userId: 'user-123', - refreshToken: 'refresh-token-value', - userAgent: 'Mozilla/5.0', - ipAddress: '127.0.0.1', - expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), - createdAt: new Date(), - lastActiveAt: new Date(), - }]) - ); - - // Mock: Get user for access token - mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockUser])); - - const result = await tokenService.createSession( - 'user-123', - 'Mozilla/5.0', - '127.0.0.1', - { device: 'desktop' } - ); - - expect(result.session).toBeDefined(); - expect(result.session.userId).toBe('user-123'); - expect(result.tokens).toBeDefined(); - expect(result.tokens.accessToken).toBeTruthy(); - expect(result.tokens.refreshToken).toBeTruthy(); - expect(result.tokens.tokenType).toBe('Bearer'); - expect(result.tokens.expiresIn).toBe(900); // 15 minutes in seconds - }); - - it('should store device information', async () => { - const deviceInfo = { - browser: 'Chrome', - os: 'Windows 10', - device: 'desktop', - }; - - // Mock: Insert session - mockDb.query.mockResolvedValueOnce( - createMockQueryResult([{ - id: 'session-123', - userId: 'user-123', - refreshToken: 'refresh-token-value', - deviceInfo, - expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), - createdAt: new Date(), - lastActiveAt: new Date(), - }]) - ); - - // Mock: Get user - mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockUser])); - - const result = await tokenService.createSession( - 'user-123', - 'Mozilla/5.0', - '127.0.0.1', - deviceInfo - ); - - // Verify INSERT query includes device info - const insertQuery = mockDb.query.mock.calls[0][0]; - expect(insertQuery).toContain('device_info'); - }); - }); - - describe('refreshSession', () => { - it('should refresh tokens for valid session', async () => { - const refreshToken = tokenService.generateRefreshToken('user-123', 'session-123'); - - const mockSession: Session = { - id: 'session-123', - userId: 'user-123', - refreshToken: 'refresh-token-value', - expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), - createdAt: new Date(), - lastActiveAt: new Date(), - }; - - // Mock: Get session - mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockSession])); - - // Mock: Update last active - mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); - - // Mock: Get user - mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockUser])); - - const result = await tokenService.refreshSession(refreshToken); - - expect(result).toBeDefined(); - expect(result?.accessToken).toBeTruthy(); - expect(result?.refreshToken).toBeTruthy(); - expect(result?.tokenType).toBe('Bearer'); - }); - - it('should return null for invalid refresh token', async () => { - const result = await tokenService.refreshSession('invalid-token'); - expect(result).toBeNull(); - }); - - it('should return null for revoked session', async () => { - const refreshToken = tokenService.generateRefreshToken('user-123', 'session-123'); - - // Mock: Session is revoked - mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); - - const result = await tokenService.refreshSession(refreshToken); - expect(result).toBeNull(); - }); - - it('should return null for expired session', async () => { - const refreshToken = tokenService.generateRefreshToken('user-123', 'session-123'); - - const expiredSession: Session = { - id: 'session-123', - userId: 'user-123', - refreshToken: 'refresh-token-value', - expiresAt: new Date(Date.now() - 1000), // Expired - createdAt: new Date(), - lastActiveAt: new Date(), - }; - - // Mock: Session exists but is expired - mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); - - const result = await tokenService.refreshSession(refreshToken); - expect(result).toBeNull(); - }); - }); - - describe('revokeSession', () => { - it('should revoke an active session', async () => { - // Mock: Revoke session - mockDb.query.mockResolvedValueOnce({ - command: 'UPDATE', - rowCount: 1, - rows: [], - oid: 0, - fields: [], - }); - - const result = await tokenService.revokeSession('session-123', 'user-123'); - expect(result).toBe(true); - }); - - it('should return false if session not found', async () => { - // Mock: Session not found - mockDb.query.mockResolvedValueOnce({ - command: 'UPDATE', - rowCount: 0, - rows: [], - oid: 0, - fields: [], - }); - - const result = await tokenService.revokeSession('session-123', 'user-123'); - expect(result).toBe(false); - }); - }); - - describe('revokeAllUserSessions', () => { - it('should revoke all user sessions', async () => { - // Mock: Revoke all sessions - mockDb.query.mockResolvedValueOnce({ - command: 'UPDATE', - rowCount: 3, - rows: [], - oid: 0, - fields: [], - }); - - const result = await tokenService.revokeAllUserSessions('user-123'); - expect(result).toBe(3); - }); - - it('should revoke all sessions except specified one', async () => { - // Mock: Revoke all except one - mockDb.query.mockResolvedValueOnce({ - command: 'UPDATE', - rowCount: 2, - rows: [], - oid: 0, - fields: [], - }); - - const result = await tokenService.revokeAllUserSessions('user-123', 'keep-session-123'); - expect(result).toBe(2); - - // Verify query includes exception - const query = mockDb.query.mock.calls[0][0]; - expect(query).toContain('id != $2'); - }); - - it('should return 0 if no sessions found', async () => { - // Mock: No sessions to revoke - mockDb.query.mockResolvedValueOnce({ - command: 'UPDATE', - rowCount: 0, - rows: [], - oid: 0, - fields: [], - }); - - const result = await tokenService.revokeAllUserSessions('user-123'); - expect(result).toBe(0); - }); - }); - - describe('getActiveSessions', () => { - it('should return all active sessions for user', async () => { - const mockSessions: Session[] = [ - { - id: 'session-1', - userId: 'user-123', - refreshToken: 'token-1', - userAgent: 'Chrome', - ipAddress: '127.0.0.1', - expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), - createdAt: new Date(), - lastActiveAt: new Date(), - }, - { - id: 'session-2', - userId: 'user-123', - refreshToken: 'token-2', - userAgent: 'Firefox', - ipAddress: '127.0.0.2', - expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), - createdAt: new Date(), - lastActiveAt: new Date(), - }, - ]; - - // Mock: Get sessions - mockDb.query.mockResolvedValueOnce(createMockQueryResult(mockSessions)); - - const result = await tokenService.getActiveSessions('user-123'); - expect(result).toHaveLength(2); - expect(result[0].id).toBe('session-1'); - expect(result[1].id).toBe('session-2'); - }); - - it('should return empty array if no active sessions', async () => { - // Mock: No sessions - mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); - - const result = await tokenService.getActiveSessions('user-123'); - expect(result).toHaveLength(0); - }); - }); - - describe('generateEmailToken', () => { - it('should generate a random email token', () => { - const token1 = tokenService.generateEmailToken(); - const token2 = tokenService.generateEmailToken(); - - expect(token1).toBeTruthy(); - expect(token2).toBeTruthy(); - expect(token1).not.toBe(token2); - expect(token1.length).toBe(64); // 32 bytes = 64 hex chars - }); - }); - - describe('hashToken', () => { - it('should hash a token consistently', () => { - const token = 'test-token-123'; - const hash1 = tokenService.hashToken(token); - const hash2 = tokenService.hashToken(token); - - expect(hash1).toBeTruthy(); - expect(hash1).toBe(hash2); - expect(hash1.length).toBe(64); // SHA-256 = 64 hex chars - }); - - it('should produce different hashes for different tokens', () => { - const hash1 = tokenService.hashToken('token-1'); - const hash2 = tokenService.hashToken('token-2'); - - expect(hash1).not.toBe(hash2); - }); - }); - - describe('parseExpiry', () => { - it('should parse different time formats correctly', () => { - // Access private method via type assertion for testing - const service = tokenService as unknown as { - parseExpiry: (expiry: string) => number; - }; - - expect(service.parseExpiry('60s')).toBe(60 * 1000); - expect(service.parseExpiry('15m')).toBe(15 * 60 * 1000); - expect(service.parseExpiry('2h')).toBe(2 * 60 * 60 * 1000); - expect(service.parseExpiry('7d')).toBe(7 * 24 * 60 * 60 * 1000); - }); - }); -}); diff --git a/apps/backend/src/modules/auth/services/email.service.ts b/apps/backend/src/modules/auth/services/email.service.ts deleted file mode 100644 index fea74bf..0000000 --- a/apps/backend/src/modules/auth/services/email.service.ts +++ /dev/null @@ -1,583 +0,0 @@ -// ============================================================================ -// OrbiQuant IA - Email Authentication Service -// ============================================================================ - -import bcrypt from 'bcryptjs'; -import nodemailer from 'nodemailer'; -import { config } from '../../../config'; -import { db } from '../../../shared/database'; -import { tokenService } from './token.service'; -import { twoFactorService } from './twofa.service'; -import { logger } from '../../../shared/utils/logger'; -import type { - User, - Profile, - AuthResponse, - RegisterEmailRequest, - LoginEmailRequest, -} from '../types/auth.types'; - -interface EmailVerification { - id: string; - email: string; - token: string; - tokenHash: string; - userId?: string; - purpose: string; - used: boolean; - expiresAt: Date; - usedAt?: Date; - createdAt: Date; -} - -export class EmailService { - private transporter: nodemailer.Transporter; - - constructor() { - this.transporter = nodemailer.createTransport({ - host: config.email.host, - port: config.email.port, - secure: config.email.secure, - auth: { - user: config.email.user, - pass: config.email.password, - }, - }); - } - - private async hashPassword(password: string): Promise { - return bcrypt.hash(password, 12); - } - - private async verifyPassword(password: string, hash: string): Promise { - return bcrypt.compare(password, hash); - } - - async register( - data: RegisterEmailRequest, - userAgent?: string, - ipAddress?: string - ): Promise<{ userId: string; message: string }> { - const { email, password, firstName, lastName, acceptTerms } = data; - - if (!acceptTerms) { - throw new Error('You must accept the terms and conditions'); - } - - // Check if user exists - const existing = await db.query( - 'SELECT id FROM users WHERE email = $1', - [email.toLowerCase()] - ); - - if (existing.rows.length > 0) { - throw new Error('Email already registered'); - } - - // Validate password strength - this.validatePassword(password); - - const client = await db.getClient(); - - try { - await client.query('BEGIN'); - - // Hash password - const hashedPassword = await this.hashPassword(password); - - // Create user - const userResult = await client.query( - `INSERT INTO users (email, encrypted_password, primary_auth_provider, status) - VALUES ($1, $2, 'email', 'pending') - RETURNING *`, - [email.toLowerCase(), hashedPassword] - ); - const user = userResult.rows[0]; - - // Create profile - await client.query( - `INSERT INTO profiles (user_id, first_name, last_name, display_name) - VALUES ($1, $2, $3, $4)`, - [user.id, firstName, lastName, `${firstName || ''} ${lastName || ''}`.trim() || null] - ); - - // Create user settings - await client.query( - 'INSERT INTO user_settings (user_id) VALUES ($1)', - [user.id] - ); - - await client.query('COMMIT'); - - // Send verification email - await this.sendVerificationEmail(user.id, email); - - // Log event - await db.query( - `INSERT INTO auth_logs (user_id, event, provider, ip_address, user_agent, success) - VALUES ($1, 'register', 'email', $2, $3, true)`, - [user.id, ipAddress, userAgent] - ); - - logger.info('User registered', { userId: user.id, email }); - - return { - userId: user.id, - message: 'Registration successful. Please check your email to verify your account.', - }; - } catch (error) { - await client.query('ROLLBACK'); - throw error; - } finally { - client.release(); - } - } - - async login( - data: LoginEmailRequest, - userAgent?: string, - ipAddress?: string - ): Promise { - const { email, password, totpCode } = data; - - // Get user - const userResult = await db.query( - 'SELECT * FROM users WHERE email = $1', - [email.toLowerCase()] - ); - - if (userResult.rows.length === 0) { - await this.logFailedLogin(null, ipAddress, userAgent, 'User not found'); - throw new Error('Invalid email or password'); - } - - const user = userResult.rows[0]; - - // Check if account is locked - if (user.lockedUntil && new Date(user.lockedUntil) > new Date()) { - throw new Error('Account is temporarily locked. Please try again later.'); - } - - // Check status - if (user.status === 'banned') { - throw new Error('Account has been suspended'); - } - - // Verify password - if (!user.encryptedPassword) { - throw new Error('Please use your social login method'); - } - - const validPassword = await this.verifyPassword(password, user.encryptedPassword); - - if (!validPassword) { - await this.handleFailedLogin(user.id, ipAddress, userAgent); - throw new Error('Invalid email or password'); - } - - // Check email verification - if (!user.emailVerified && user.status === 'pending') { - throw new Error('Please verify your email before logging in'); - } - - // Check 2FA - if (user.totpEnabled) { - if (!totpCode) { - return { - user: { id: user.id } as Omit, - tokens: { accessToken: '', refreshToken: '', expiresIn: 0, tokenType: 'Bearer' }, - requiresTwoFactor: true, - }; - } - - // Verify TOTP code - will be handled by 2FA service - const valid = await this.verifyTOTP(user.id, totpCode); - if (!valid) { - throw new Error('Invalid 2FA code'); - } - } - - // Reset failed attempts on successful login - await db.query( - 'UPDATE users SET failed_login_attempts = 0, locked_until = NULL, last_login_at = NOW(), last_login_ip = $1 WHERE id = $2', - [ipAddress, user.id] - ); - - // Get profile - const profileResult = await db.query( - 'SELECT * FROM profiles WHERE user_id = $1', - [user.id] - ); - const profile = profileResult.rows[0]; - - // Log success - await db.query( - `INSERT INTO auth_logs (user_id, event, provider, ip_address, user_agent, success) - VALUES ($1, 'login_success', 'email', $2, $3, true)`, - [user.id, ipAddress, userAgent] - ); - - // Create session - const { tokens } = await tokenService.createSession(user.id, userAgent, ipAddress); - - // Remove sensitive data - const { encryptedPassword: _, ...safeUser } = user; - - return { - user: safeUser as Omit, - profile, - tokens, - }; - } - - private async handleFailedLogin( - userId: string, - ipAddress?: string, - userAgent?: string - ): Promise { - // Increment failed attempts - const result = await db.query<{ failed_login_attempts: number }>( - `UPDATE users - SET failed_login_attempts = failed_login_attempts + 1 - WHERE id = $1 - RETURNING failed_login_attempts`, - [userId] - ); - - const attempts = result.rows[0].failed_login_attempts; - - // Lock account after 5 failed attempts - if (attempts >= 5) { - const lockDuration = Math.min(attempts * 5, 60); // Max 60 minutes - await db.query( - `UPDATE users SET locked_until = NOW() + INTERVAL '${lockDuration} minutes' WHERE id = $1`, - [userId] - ); - - await db.query( - `INSERT INTO auth_logs (user_id, event, provider, ip_address, user_agent, success, metadata) - VALUES ($1, 'account_locked', 'email', $2, $3, true, $4)`, - [userId, ipAddress, userAgent, JSON.stringify({ lockDurationMinutes: lockDuration })] - ); - } - - await this.logFailedLogin(userId, ipAddress, userAgent, 'Invalid password'); - } - - private async logFailedLogin( - userId: string | null, - ipAddress?: string, - userAgent?: string, - reason?: string - ): Promise { - await db.query( - `INSERT INTO auth_logs (user_id, event, provider, ip_address, user_agent, success, error_message) - VALUES ($1, 'login_failed', 'email', $2, $3, false, $4)`, - [userId, ipAddress, userAgent, reason] - ); - } - - async sendVerificationEmail(userId: string, email: string): Promise { - const token = tokenService.generateEmailToken(); - const tokenHash = tokenService.hashToken(token); - const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours - - // Store verification token - await db.query( - `INSERT INTO email_verifications (email, token, token_hash, user_id, purpose, expires_at) - VALUES ($1, $2, $3, $4, 'verify', $5)`, - [email, token, tokenHash, userId, expiresAt] - ); - - // Send email - const verificationUrl = `${config.app.frontendUrl}/verify-email?token=${token}`; - - await this.transporter.sendMail({ - from: `"OrbiQuant" <${config.email.from}>`, - to: email, - subject: 'Verifica tu cuenta de OrbiQuant', - html: this.getVerificationEmailTemplate(verificationUrl), - }); - - logger.info('Verification email sent', { email }); - } - - async verifyEmail(token: string): Promise<{ success: boolean; message: string }> { - const tokenHash = tokenService.hashToken(token); - - const result = await db.query( - `SELECT * FROM email_verifications - WHERE token_hash = $1 AND purpose = 'verify' AND used = FALSE AND expires_at > NOW()`, - [tokenHash] - ); - - if (result.rows.length === 0) { - throw new Error('Invalid or expired verification link'); - } - - const verification = result.rows[0]; - - // Mark token as used - await db.query( - 'UPDATE email_verifications SET used = TRUE, used_at = NOW() WHERE id = $1', - [verification.id] - ); - - // Activate user - await db.query( - `UPDATE users SET email_verified = TRUE, status = 'active', confirmed_at = NOW() - WHERE id = $1`, - [verification.userId] - ); - - // Log event - await db.query( - `INSERT INTO auth_logs (user_id, event, provider, success) - VALUES ($1, 'email_verified', 'email', true)`, - [verification.userId] - ); - - return { - success: true, - message: 'Email verified successfully. You can now log in.', - }; - } - - async sendPasswordResetEmail(email: string): Promise<{ message: string }> { - const userResult = await db.query( - 'SELECT id FROM users WHERE email = $1', - [email.toLowerCase()] - ); - - // Don't reveal if user exists - if (userResult.rows.length === 0) { - return { message: 'If an account exists with this email, a reset link has been sent.' }; - } - - const user = userResult.rows[0]; - const token = tokenService.generateEmailToken(); - const tokenHash = tokenService.hashToken(token); - const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour - - // Store reset token - await db.query( - `INSERT INTO email_verifications (email, token, token_hash, user_id, purpose, expires_at) - VALUES ($1, $2, $3, $4, 'reset_password', $5)`, - [email.toLowerCase(), token, tokenHash, user.id, expiresAt] - ); - - // Send email - const resetUrl = `${config.app.frontendUrl}/reset-password?token=${token}`; - - await this.transporter.sendMail({ - from: `"OrbiQuant" <${config.email.from}>`, - to: email, - subject: 'Restablece tu contraseña de OrbiQuant', - html: this.getPasswordResetEmailTemplate(resetUrl), - }); - - // Log event - await db.query( - `INSERT INTO auth_logs (user_id, event, provider, success) - VALUES ($1, 'password_reset_request', 'email', true)`, - [user.id] - ); - - logger.info('Password reset email sent', { email }); - - return { message: 'If an account exists with this email, a reset link has been sent.' }; - } - - async resetPassword(token: string, newPassword: string): Promise<{ message: string }> { - const tokenHash = tokenService.hashToken(token); - - const result = await db.query( - `SELECT * FROM email_verifications - WHERE token_hash = $1 AND purpose = 'reset_password' AND used = FALSE AND expires_at > NOW()`, - [tokenHash] - ); - - if (result.rows.length === 0) { - throw new Error('Invalid or expired reset link'); - } - - const verification = result.rows[0]; - - // Validate new password - this.validatePassword(newPassword); - - // Hash new password - const hashedPassword = await this.hashPassword(newPassword); - - // Update password - await db.query( - 'UPDATE users SET encrypted_password = $1, updated_at = NOW() WHERE id = $2', - [hashedPassword, verification.userId] - ); - - // Mark token as used - await db.query( - 'UPDATE email_verifications SET used = TRUE, used_at = NOW() WHERE id = $1', - [verification.id] - ); - - // Revoke all sessions - await tokenService.revokeAllUserSessions(verification.userId!); - - // Log event - await db.query( - `INSERT INTO auth_logs (user_id, event, provider, success) - VALUES ($1, 'password_reset_complete', 'email', true)`, - [verification.userId] - ); - - return { message: 'Password reset successfully. Please log in with your new password.' }; - } - - async changePassword( - userId: string, - currentPassword: string, - newPassword: string - ): Promise<{ message: string }> { - // Get user - const userResult = await db.query( - 'SELECT * FROM users WHERE id = $1', - [userId] - ); - - if (userResult.rows.length === 0) { - throw new Error('User not found'); - } - - const user = userResult.rows[0]; - - if (!user.encryptedPassword) { - throw new Error('Cannot change password for social login accounts'); - } - - // Verify current password - const valid = await this.verifyPassword(currentPassword, user.encryptedPassword); - if (!valid) { - throw new Error('Current password is incorrect'); - } - - // Validate new password - this.validatePassword(newPassword); - - // Hash and update - const hashedPassword = await this.hashPassword(newPassword); - - await db.query( - 'UPDATE users SET encrypted_password = $1, updated_at = NOW() WHERE id = $2', - [hashedPassword, userId] - ); - - return { message: 'Password changed successfully' }; - } - - private validatePassword(password: string): void { - if (password.length < 8) { - throw new Error('Password must be at least 8 characters long'); - } - - if (!/[A-Z]/.test(password)) { - throw new Error('Password must contain at least one uppercase letter'); - } - - if (!/[a-z]/.test(password)) { - throw new Error('Password must contain at least one lowercase letter'); - } - - if (!/[0-9]/.test(password)) { - throw new Error('Password must contain at least one number'); - } - - if (!/[!@#$%^&*(),.?":{}|<>]/.test(password)) { - throw new Error('Password must contain at least one special character'); - } - } - - private async verifyTOTP(userId: string, code: string): Promise { - return twoFactorService.verifyTOTP(userId, code); - } - - private getVerificationEmailTemplate(url: string): string { - return ` - - - - - - - -
-
- -
-
-

Verifica tu cuenta

-

Gracias por registrarte en OrbiQuant. Haz clic en el boton de abajo para verificar tu cuenta:

-

- Verificar Email -

-

Si no creaste esta cuenta, puedes ignorar este email.

-

Este enlace expira en 24 horas.

-
- -
- - - `; - } - - private getPasswordResetEmailTemplate(url: string): string { - return ` - - - - - - - -
-
- -
-
-

Restablece tu contrasena

-

Recibimos una solicitud para restablecer la contrasena de tu cuenta. Haz clic en el boton de abajo:

-

- Restablecer Contrasena -

-

Si no solicitaste este cambio, puedes ignorar este email. Tu contrasena no sera modificada.

-

Este enlace expira en 1 hora.

-
- -
- - - `; - } -} - -export const emailService = new EmailService(); diff --git a/apps/backend/src/modules/auth/services/oauth.service.ts b/apps/backend/src/modules/auth/services/oauth.service.ts deleted file mode 100644 index 5369c5f..0000000 --- a/apps/backend/src/modules/auth/services/oauth.service.ts +++ /dev/null @@ -1,624 +0,0 @@ -// ============================================================================ -// OrbiQuant IA - OAuth Service -// ============================================================================ - -import { OAuth2Client } from 'google-auth-library'; -import axios from 'axios'; -import jwt from 'jsonwebtoken'; -import crypto from 'crypto'; -import { config } from '../../../config'; -import { db } from '../../../shared/database'; -import { tokenService } from './token.service'; -import { logger } from '../../../shared/utils/logger'; -import type { - AuthProvider, - OAuthAccount, - OAuthCallbackData, - User, - Profile, - AuthResponse, -} from '../types/auth.types'; - -export class OAuthService { - private googleClient: OAuth2Client; - - constructor() { - this.googleClient = new OAuth2Client( - config.oauth.google.clientId, - config.oauth.google.clientSecret, - config.oauth.google.callbackUrl - ); - } - - // ============================================================================ - // Google OAuth - // ============================================================================ - - getGoogleAuthUrl(state: string): string { - return this.googleClient.generateAuthUrl({ - access_type: 'offline', - scope: config.oauth.google.scope, - state, - prompt: 'consent', - }); - } - - async verifyGoogleToken(code: string): Promise { - try { - const { tokens } = await this.googleClient.getToken(code); - this.googleClient.setCredentials(tokens); - - const ticket = await this.googleClient.verifyIdToken({ - idToken: tokens.id_token!, - audience: config.oauth.google.clientId, - }); - - const payload = ticket.getPayload(); - if (!payload) return null; - - return { - provider: 'google', - providerAccountId: payload.sub, - email: payload.email, - name: payload.name, - avatarUrl: payload.picture, - accessToken: tokens.access_token!, - refreshToken: tokens.refresh_token ?? undefined, - expiresAt: tokens.expiry_date ? new Date(tokens.expiry_date) : undefined, - profile: payload as unknown as Record, - }; - } catch (error) { - logger.error('Google token verification failed', { error }); - return null; - } - } - - async verifyGoogleIdToken(idToken: string): Promise { - try { - const ticket = await this.googleClient.verifyIdToken({ - idToken, - audience: config.oauth.google.clientId, - }); - - const payload = ticket.getPayload(); - if (!payload) return null; - - return { - provider: 'google', - providerAccountId: payload.sub, - email: payload.email, - name: payload.name, - avatarUrl: payload.picture, - accessToken: idToken, - profile: payload as unknown as Record, - }; - } catch (error) { - logger.error('Google ID token verification failed', { error }); - return null; - } - } - - // ============================================================================ - // Facebook OAuth - // ============================================================================ - - getFacebookAuthUrl(state: string): string { - const params = new URLSearchParams({ - client_id: config.oauth.facebook.clientId, - redirect_uri: config.oauth.facebook.callbackUrl, - scope: config.oauth.facebook.scope.join(','), - state, - response_type: 'code', - }); - - return `https://www.facebook.com/v18.0/dialog/oauth?${params}`; - } - - async verifyFacebookToken(code: string): Promise { - try { - // Exchange code for access token - const tokenResponse = await axios.get('https://graph.facebook.com/v18.0/oauth/access_token', { - params: { - client_id: config.oauth.facebook.clientId, - client_secret: config.oauth.facebook.clientSecret, - redirect_uri: config.oauth.facebook.callbackUrl, - code, - }, - }); - - const { access_token, expires_in } = tokenResponse.data; - - // Get user profile - const profileResponse = await axios.get('https://graph.facebook.com/v18.0/me', { - params: { - fields: 'id,name,email,picture.type(large)', - access_token, - }, - }); - - const profile = profileResponse.data; - - return { - provider: 'facebook', - providerAccountId: profile.id, - email: profile.email, - name: profile.name, - avatarUrl: profile.picture?.data?.url, - accessToken: access_token, - expiresAt: expires_in ? new Date(Date.now() + expires_in * 1000) : undefined, - profile, - }; - } catch (error) { - logger.error('Facebook token verification failed', { error }); - return null; - } - } - - // ============================================================================ - // Twitter/X OAuth 2.0 - // ============================================================================ - - getTwitterAuthUrl(state: string, codeChallenge: string): string { - const params = new URLSearchParams({ - response_type: 'code', - client_id: config.oauth.twitter.clientId, - redirect_uri: config.oauth.twitter.callbackUrl, - scope: config.oauth.twitter.scope.join(' '), - state, - code_challenge: codeChallenge, - code_challenge_method: 'S256', - }); - - return `https://twitter.com/i/oauth2/authorize?${params}`; - } - - async verifyTwitterToken(code: string, codeVerifier: string): Promise { - try { - // Exchange code for access token - const tokenResponse = await axios.post( - 'https://api.twitter.com/2/oauth2/token', - new URLSearchParams({ - code, - grant_type: 'authorization_code', - client_id: config.oauth.twitter.clientId, - redirect_uri: config.oauth.twitter.callbackUrl, - code_verifier: codeVerifier, - }), - { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Authorization: `Basic ${Buffer.from( - `${config.oauth.twitter.clientId}:${config.oauth.twitter.clientSecret}` - ).toString('base64')}`, - }, - } - ); - - const { access_token, refresh_token, expires_in } = tokenResponse.data; - - // Get user profile - const profileResponse = await axios.get('https://api.twitter.com/2/users/me', { - params: { - 'user.fields': 'id,name,username,profile_image_url', - }, - headers: { - Authorization: `Bearer ${access_token}`, - }, - }); - - const profile = profileResponse.data.data; - - return { - provider: 'twitter', - providerAccountId: profile.id, - name: profile.name, - avatarUrl: profile.profile_image_url, - accessToken: access_token, - refreshToken: refresh_token, - expiresAt: expires_in ? new Date(Date.now() + expires_in * 1000) : undefined, - profile, - }; - } catch (error) { - logger.error('Twitter token verification failed', { error }); - return null; - } - } - - // ============================================================================ - // Apple Sign In - // ============================================================================ - - getAppleAuthUrl(state: string): string { - const params = new URLSearchParams({ - client_id: config.oauth.apple.clientId, - redirect_uri: config.oauth.apple.callbackUrl, - response_type: 'code id_token', - scope: config.oauth.apple.scope.join(' '), - response_mode: 'form_post', - state, - }); - - return `https://appleid.apple.com/auth/authorize?${params}`; - } - - async verifyAppleToken(code: string, idToken: string): Promise { - try { - // Verify ID token - const decodedHeader = jwt.decode(idToken, { complete: true }); - if (!decodedHeader) return null; - - // Get Apple's public keys - const keysResponse = await axios.get('https://appleid.apple.com/auth/keys'); - const keys = keysResponse.data.keys; - - // Find matching key - const key = keys.find((k: { kid: string }) => k.kid === decodedHeader.header.kid); - if (!key) return null; - - // Verify token (simplified - in production use proper JWT verification) - const decoded = jwt.decode(idToken) as { - sub: string; - email?: string; - email_verified?: boolean; - } | null; - - if (!decoded) return null; - - return { - provider: 'apple', - providerAccountId: decoded.sub, - email: decoded.email, - accessToken: code, - profile: decoded as unknown as Record, - }; - } catch (error) { - logger.error('Apple token verification failed', { error }); - return null; - } - } - - // ============================================================================ - // GitHub OAuth - // ============================================================================ - - getGitHubAuthUrl(state: string): string { - const params = new URLSearchParams({ - client_id: config.oauth.github.clientId, - redirect_uri: config.oauth.github.callbackUrl, - scope: config.oauth.github.scope.join(' '), - state, - }); - - return `https://github.com/login/oauth/authorize?${params}`; - } - - async verifyGitHubToken(code: string): Promise { - try { - // Exchange code for access token - const tokenResponse = await axios.post( - 'https://github.com/login/oauth/access_token', - { - client_id: config.oauth.github.clientId, - client_secret: config.oauth.github.clientSecret, - code, - redirect_uri: config.oauth.github.callbackUrl, - }, - { - headers: { - Accept: 'application/json', - }, - } - ); - - const { access_token } = tokenResponse.data; - - // Get user profile - const profileResponse = await axios.get('https://api.github.com/user', { - headers: { - Authorization: `Bearer ${access_token}`, - }, - }); - - const profile = profileResponse.data; - - // Get user email if not public - let email = profile.email; - if (!email) { - const emailsResponse = await axios.get('https://api.github.com/user/emails', { - headers: { - Authorization: `Bearer ${access_token}`, - }, - }); - const primaryEmail = emailsResponse.data.find( - (e: { primary: boolean; verified: boolean }) => e.primary && e.verified - ); - email = primaryEmail?.email; - } - - return { - provider: 'github', - providerAccountId: profile.id.toString(), - email, - name: profile.name || profile.login, - avatarUrl: profile.avatar_url, - accessToken: access_token, - profile, - }; - } catch (error) { - logger.error('GitHub token verification failed', { error }); - return null; - } - } - - // ============================================================================ - // Common OAuth Flow - // ============================================================================ - - async handleOAuthCallback( - data: OAuthCallbackData, - userAgent?: string, - ipAddress?: string - ): Promise { - // Check if OAuth account exists - const existingOAuth = await db.query( - `SELECT * FROM oauth_accounts - WHERE provider = $1 AND provider_account_id = $2`, - [data.provider, data.providerAccountId] - ); - - let user: User; - let profile: Profile | undefined; - let isNewUser = false; - - if (existingOAuth.rows.length > 0) { - // Existing OAuth account - get user - const userResult = await db.query( - 'SELECT * FROM users WHERE id = $1', - [existingOAuth.rows[0].userId] - ); - user = userResult.rows[0]; - - // Update OAuth tokens - await db.query( - `UPDATE oauth_accounts - SET access_token = $1, refresh_token = $2, token_expires_at = $3, updated_at = NOW() - WHERE id = $4`, - [data.accessToken, data.refreshToken, data.expiresAt, existingOAuth.rows[0].id] - ); - - // Get profile - const profileResult = await db.query( - 'SELECT * FROM profiles WHERE user_id = $1', - [user.id] - ); - profile = profileResult.rows[0]; - } else if (data.email) { - // Check if user with this email exists - const existingUser = await db.query( - 'SELECT * FROM users WHERE email = $1', - [data.email] - ); - - if (existingUser.rows.length > 0) { - // Link OAuth to existing user - user = existingUser.rows[0]; - - await db.query( - `INSERT INTO oauth_accounts - (user_id, provider, provider_account_id, access_token, refresh_token, token_expires_at, provider_email, provider_name, provider_avatar_url, provider_profile) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, - [ - user.id, - data.provider, - data.providerAccountId, - data.accessToken, - data.refreshToken, - data.expiresAt, - data.email, - data.name, - data.avatarUrl, - JSON.stringify(data.profile), - ] - ); - - const profileResult = await db.query( - 'SELECT * FROM profiles WHERE user_id = $1', - [user.id] - ); - profile = profileResult.rows[0]; - } else { - // Create new user - isNewUser = true; - const result = await this.createUserFromOAuth(data); - user = result.user; - profile = result.profile; - } - } else { - // No email - create user without email (for Twitter/Apple without email) - isNewUser = true; - const result = await this.createUserFromOAuth(data); - user = result.user; - profile = result.profile; - } - - // Update last login - await db.query( - `UPDATE users SET last_login_at = NOW(), last_login_ip = $1 WHERE id = $2`, - [ipAddress, user.id] - ); - - // Log auth event - await db.query( - `INSERT INTO auth_logs (user_id, event, provider, ip_address, user_agent, success) - VALUES ($1, $2, $3, $4, $5, true)`, - [user.id, isNewUser ? 'register' : 'login_success', data.provider, ipAddress, userAgent] - ); - - // Create session and tokens - const { tokens } = await tokenService.createSession(user.id, userAgent, ipAddress); - - // Remove sensitive data - const { encryptedPassword: _, ...safeUser } = user; - - return { - user: safeUser as Omit, - profile, - tokens, - isNewUser, - }; - } - - private async createUserFromOAuth(data: OAuthCallbackData): Promise<{ user: User; profile: Profile }> { - const client = await db.getClient(); - - try { - await client.query('BEGIN'); - - // Create user - const userResult = await client.query( - `INSERT INTO users (email, email_verified, primary_auth_provider, status) - VALUES ($1, $2, $3, 'active') - RETURNING *`, - [data.email || `${data.provider}_${data.providerAccountId}@oauth.orbiquant.com`, true, data.provider] - ); - const user = userResult.rows[0]; - - // Create profile - const names = data.name?.split(' ') || []; - const profileResult = await client.query( - `INSERT INTO profiles (user_id, first_name, last_name, display_name, avatar_url) - VALUES ($1, $2, $3, $4, $5) - RETURNING *`, - [user.id, names[0] || null, names.slice(1).join(' ') || null, data.name, data.avatarUrl] - ); - const profile = profileResult.rows[0]; - - // Create user settings - await client.query( - 'INSERT INTO user_settings (user_id) VALUES ($1)', - [user.id] - ); - - // Create OAuth account - await client.query( - `INSERT INTO oauth_accounts - (user_id, provider, provider_account_id, access_token, refresh_token, token_expires_at, provider_email, provider_name, provider_avatar_url, provider_profile) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, - [ - user.id, - data.provider, - data.providerAccountId, - data.accessToken, - data.refreshToken, - data.expiresAt, - data.email, - data.name, - data.avatarUrl, - JSON.stringify(data.profile), - ] - ); - - await client.query('COMMIT'); - - return { user, profile }; - } catch (error) { - await client.query('ROLLBACK'); - throw error; - } finally { - client.release(); - } - } - - async linkOAuthAccount(userId: string, data: OAuthCallbackData): Promise { - // Check if already linked - const existing = await db.query( - 'SELECT * FROM oauth_accounts WHERE user_id = $1 AND provider = $2', - [userId, data.provider] - ); - - if (existing.rows.length > 0) { - throw new Error(`${data.provider} account already linked`); - } - - // Check if OAuth account is linked to another user - const otherUser = await db.query( - 'SELECT * FROM oauth_accounts WHERE provider = $1 AND provider_account_id = $2', - [data.provider, data.providerAccountId] - ); - - if (otherUser.rows.length > 0) { - throw new Error(`This ${data.provider} account is already linked to another user`); - } - - const result = await db.query( - `INSERT INTO oauth_accounts - (user_id, provider, provider_account_id, access_token, refresh_token, token_expires_at, provider_email, provider_name, provider_avatar_url, provider_profile) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - RETURNING *`, - [ - userId, - data.provider, - data.providerAccountId, - data.accessToken, - data.refreshToken, - data.expiresAt, - data.email, - data.name, - data.avatarUrl, - JSON.stringify(data.profile), - ] - ); - - return result.rows[0]; - } - - async unlinkOAuthAccount(userId: string, provider: AuthProvider): Promise { - // Check if user has other auth methods - const user = await db.query( - 'SELECT * FROM users WHERE id = $1', - [userId] - ); - - const oauthAccounts = await db.query( - 'SELECT * FROM oauth_accounts WHERE user_id = $1', - [userId] - ); - - const hasPassword = !!user.rows[0].encryptedPassword; - const hasOtherOAuth = oauthAccounts.rows.length > 1; - - if (!hasPassword && !hasOtherOAuth) { - throw new Error('Cannot unlink the only authentication method'); - } - - await db.query( - 'DELETE FROM oauth_accounts WHERE user_id = $1 AND provider = $2', - [userId, provider] - ); - } - - async getLinkedAccounts(userId: string): Promise { - const result = await db.query( - `SELECT id, user_id, provider, provider_account_id, provider_email, provider_name, provider_avatar_url, created_at - FROM oauth_accounts WHERE user_id = $1`, - [userId] - ); - - return result.rows; - } - - // PKCE helpers for Twitter - generateCodeVerifier(): string { - return crypto.randomBytes(32).toString('base64url'); - } - - generateCodeChallenge(verifier: string): string { - return crypto.createHash('sha256').update(verifier).digest('base64url'); - } - - generateState(): string { - return crypto.randomBytes(16).toString('hex'); - } -} - -export const oauthService = new OAuthService(); diff --git a/apps/backend/src/modules/auth/services/phone.service.ts b/apps/backend/src/modules/auth/services/phone.service.ts deleted file mode 100644 index 4e61faa..0000000 --- a/apps/backend/src/modules/auth/services/phone.service.ts +++ /dev/null @@ -1,435 +0,0 @@ -// ============================================================================ -// OrbiQuant IA - Phone Authentication Service (SMS/WhatsApp) -// ============================================================================ - -import twilio from 'twilio'; -import crypto from 'crypto'; -import { config } from '../../../config'; -import { db } from '../../../shared/database'; -import { tokenService } from './token.service'; -import { logger } from '../../../shared/utils/logger'; -import type { User, Profile, AuthResponse } from '../types/auth.types'; - -type PhoneChannel = 'sms' | 'whatsapp' | 'call'; - -interface PhoneVerification { - id: string; - phoneNumber: string; - countryCode: string; - otpCode: string; - otpHash: string; - channel: PhoneChannel; - verified: boolean; - attempts: number; - maxAttempts: number; - userId?: string; - purpose: string; - expiresAt: Date; - verifiedAt?: Date; - createdAt: Date; -} - -export class PhoneService { - private twilioClient: twilio.Twilio | null = null; - private verifyServiceSid: string = ''; - private isConfigured: boolean = false; - - constructor() { - // Only initialize Twilio if credentials are properly configured - const accountSid = config.twilio?.accountSid; - const authToken = config.twilio?.authToken; - - if (accountSid && authToken && accountSid.startsWith('AC')) { - try { - this.twilioClient = twilio(accountSid, authToken); - this.verifyServiceSid = config.twilio.verifyServiceSid || ''; - this.isConfigured = true; - logger.info('[PhoneService] Twilio initialized successfully'); - } catch (_error) { - logger.warn('[PhoneService] Failed to initialize Twilio:', _error); - this.isConfigured = false; - } - } else { - logger.warn('[PhoneService] Twilio not configured - phone features disabled'); - this.verifyServiceSid = ''; - this.isConfigured = false; - } - } - - private ensureConfigured(): void { - if (!this.isConfigured || !this.twilioClient) { - throw new Error('Phone service is not configured. Please set valid Twilio credentials.'); - } - } - - private generateOTP(): string { - return Math.floor(100000 + Math.random() * 900000).toString(); - } - - private hashOTP(otp: string, _salt: string): string { - return crypto.createHmac('sha256', _salt).update(otp).digest('hex'); - } - - private formatPhoneNumber(phoneNumber: string, countryCode: string): string { - // Remove any non-numeric characters except + - const cleaned = phoneNumber.replace(/[^\d+]/g, ''); - - // Add country code if not present - if (!cleaned.startsWith('+')) { - return `+${countryCode}${cleaned}`; - } - - return cleaned; - } - - async sendOTP( - phoneNumber: string, - countryCode: string, - channel: PhoneChannel = 'whatsapp', - purpose: string = 'login', - userId?: string - ): Promise<{ success: boolean; expiresAt: Date; message: string }> { - const formattedPhone = this.formatPhoneNumber(phoneNumber, countryCode); - - // Check rate limiting - max 3 OTPs per phone per hour - const recentOTPs = await db.query( - `SELECT COUNT(*) FROM phone_verifications - WHERE phone_number = $1 AND created_at > NOW() - INTERVAL '1 hour'`, - [formattedPhone] - ); - - if (parseInt(recentOTPs.rows[0].count) >= 3) { - throw new Error('Too many OTP requests. Please try again later.'); - } - - // Generate OTP - const otpCode = this.generateOTP(); - const salt = crypto.randomBytes(16).toString('hex'); - const otpHash = this.hashOTP(otpCode, salt); - const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes - - try { - this.ensureConfigured(); - - // Use Twilio Verify service - if (config.twilio.useVerifyService) { - await this.twilioClient!.verify.v2 - .services(this.verifyServiceSid) - .verifications.create({ - to: formattedPhone, - channel: channel === 'whatsapp' ? 'whatsapp' : channel, - }); - } else { - // Send via regular SMS/WhatsApp - const toNumber = channel === 'whatsapp' ? `whatsapp:${formattedPhone}` : formattedPhone; - const fromNumber = - channel === 'whatsapp' - ? `whatsapp:${config.twilio.whatsappNumber}` - : config.twilio.phoneNumber; - - await this.twilioClient!.messages.create({ - body: `Tu codigo de verificacion OrbiQuant es: ${otpCode}. Expira en 10 minutos.`, - from: fromNumber, - to: toNumber, - }); - } - - // Store verification in database - await db.query( - `INSERT INTO phone_verifications - (phone_number, country_code, otp_code, otp_hash, channel, purpose, user_id, expires_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, - [formattedPhone, countryCode, config.twilio.useVerifyService ? '' : otpCode, otpHash, channel, purpose, userId, expiresAt] - ); - - logger.info('OTP sent successfully', { phone: formattedPhone, channel, purpose }); - - return { - success: true, - expiresAt, - message: `Codigo enviado via ${channel === 'whatsapp' ? 'WhatsApp' : 'SMS'}`, - }; - } catch (error) { - logger.error('Failed to send OTP', { error, phone: formattedPhone, channel }); - throw new Error('Failed to send verification code. Please try again.'); - } - } - - async verifyOTP( - phoneNumber: string, - countryCode: string, - otpCode: string, - userAgent?: string, - ipAddress?: string - ): Promise { - const formattedPhone = this.formatPhoneNumber(phoneNumber, countryCode); - - if (config.twilio.useVerifyService) { - // Use Twilio Verify service - this.ensureConfigured(); - try { - const verification = await this.twilioClient!.verify.v2 - .services(this.verifyServiceSid) - .verificationChecks.create({ - to: formattedPhone, - code: otpCode, - }); - - if (verification.status !== 'approved') { - throw new Error('Invalid or expired verification code'); - } - } catch { - throw new Error('Invalid or expired verification code'); - } - } else { - // Manual verification - const verificationResult = await db.query( - `SELECT * FROM phone_verifications - WHERE phone_number = $1 - AND expires_at > NOW() - AND verified = FALSE - AND attempts < max_attempts - ORDER BY created_at DESC - LIMIT 1`, - [formattedPhone] - ); - - if (verificationResult.rows.length === 0) { - throw new Error('No pending verification found or code expired'); - } - - const verification = verificationResult.rows[0]; - - // Update attempts - await db.query( - 'UPDATE phone_verifications SET attempts = attempts + 1 WHERE id = $1', - [verification.id] - ); - - // Verify OTP - if (verification.otpCode !== otpCode) { - if (verification.attempts + 1 >= verification.maxAttempts) { - throw new Error('Maximum attempts exceeded. Please request a new code.'); - } - throw new Error('Invalid verification code'); - } - - // Mark as verified - await db.query( - 'UPDATE phone_verifications SET verified = TRUE, verified_at = NOW() WHERE id = $1', - [verification.id] - ); - } - - // Check if user exists with this phone - const existingUser = await db.query( - 'SELECT * FROM users WHERE phone = $1', - [formattedPhone] - ); - - let user: User; - let profile: Profile | undefined; - let isNewUser = false; - - if (existingUser.rows.length > 0) { - user = existingUser.rows[0]; - - // Update phone verified status - if (!user.phoneVerified) { - await db.query( - 'UPDATE users SET phone_verified = TRUE WHERE id = $1', - [user.id] - ); - user.phoneVerified = true; - } - - // Get profile - const profileResult = await db.query( - 'SELECT * FROM profiles WHERE user_id = $1', - [user.id] - ); - profile = profileResult.rows[0]; - } else { - // Create new user with phone - isNewUser = true; - const result = await this.createUserFromPhone(formattedPhone); - user = result.user; - profile = result.profile; - } - - // Update last login - await db.query( - 'UPDATE users SET last_login_at = NOW(), last_login_ip = $1 WHERE id = $2', - [ipAddress, user.id] - ); - - // Log auth event - await db.query( - `INSERT INTO auth_logs (user_id, event, provider, ip_address, user_agent, success) - VALUES ($1, $2, 'phone', $3, $4, true)`, - [user.id, isNewUser ? 'register' : 'login_success', ipAddress, userAgent] - ); - - // Create session and tokens - const { tokens } = await tokenService.createSession(user.id, userAgent, ipAddress); - - // Remove sensitive data - const { encryptedPassword: _, ...safeUser } = user; - - return { - user: safeUser as Omit, - profile, - tokens, - isNewUser, - }; - } - - private async createUserFromPhone(phone: string): Promise<{ user: User; profile: Profile }> { - const client = await db.getClient(); - - try { - await client.query('BEGIN'); - - // Create user - const userResult = await client.query( - `INSERT INTO users (phone, phone_verified, primary_auth_provider, status) - VALUES ($1, TRUE, 'phone', 'active') - RETURNING *`, - [phone] - ); - const user = userResult.rows[0]; - - // Create empty profile - const profileResult = await client.query( - 'INSERT INTO profiles (user_id) VALUES ($1) RETURNING *', - [user.id] - ); - const profile = profileResult.rows[0]; - - // Create user settings - await client.query( - 'INSERT INTO user_settings (user_id) VALUES ($1)', - [user.id] - ); - - await client.query('COMMIT'); - - return { user, profile }; - } catch (error) { - await client.query('ROLLBACK'); - throw error; - } finally { - client.release(); - } - } - - async linkPhoneToUser( - userId: string, - phoneNumber: string, - countryCode: string - ): Promise { - const formattedPhone = this.formatPhoneNumber(phoneNumber, countryCode); - - // Check if phone already linked to another user - const existing = await db.query( - 'SELECT id FROM users WHERE phone = $1 AND id != $2', - [formattedPhone, userId] - ); - - if (existing.rows.length > 0) { - throw new Error('This phone number is already linked to another account'); - } - - // Update user - await db.query( - 'UPDATE users SET phone = $1, phone_verified = FALSE WHERE id = $2', - [formattedPhone, userId] - ); - - // Send verification OTP - await this.sendOTP(phoneNumber, countryCode, 'whatsapp', 'verify', userId); - } - - async verifyLinkedPhone( - userId: string, - phoneNumber: string, - countryCode: string, - otpCode: string - ): Promise { - const formattedPhone = this.formatPhoneNumber(phoneNumber, countryCode); - - // Get user - const userResult = await db.query( - 'SELECT * FROM users WHERE id = $1', - [userId] - ); - - if (userResult.rows.length === 0) { - throw new Error('User not found'); - } - - const user = userResult.rows[0]; - - if (user.phone !== formattedPhone) { - throw new Error('Phone number does not match'); - } - - // Verify OTP using Twilio or manual - if (config.twilio.useVerifyService) { - this.ensureConfigured(); - const verification = await this.twilioClient!.verify.v2 - .services(this.verifyServiceSid) - .verificationChecks.create({ - to: formattedPhone, - code: otpCode, - }); - - if (verification.status !== 'approved') { - throw new Error('Invalid or expired verification code'); - } - } else { - // Manual verification - const verificationResult = await db.query( - `SELECT * FROM phone_verifications - WHERE phone_number = $1 - AND user_id = $2 - AND purpose = 'verify' - AND expires_at > NOW() - AND verified = FALSE - ORDER BY created_at DESC - LIMIT 1`, - [formattedPhone, userId] - ); - - if (verificationResult.rows.length === 0) { - throw new Error('No pending verification found'); - } - - const verification = verificationResult.rows[0]; - - if (verification.otpCode !== otpCode) { - throw new Error('Invalid verification code'); - } - - await db.query( - 'UPDATE phone_verifications SET verified = TRUE, verified_at = NOW() WHERE id = $1', - [verification.id] - ); - } - - // Mark phone as verified - await db.query( - 'UPDATE users SET phone_verified = TRUE WHERE id = $1', - [userId] - ); - - // Log event - await db.query( - `INSERT INTO auth_logs (user_id, event, provider, success) - VALUES ($1, 'phone_verified', 'phone', true)`, - [userId] - ); - } -} - -export const phoneService = new PhoneService(); diff --git a/apps/backend/src/modules/auth/services/token.service.ts b/apps/backend/src/modules/auth/services/token.service.ts deleted file mode 100644 index 2b89298..0000000 --- a/apps/backend/src/modules/auth/services/token.service.ts +++ /dev/null @@ -1,211 +0,0 @@ -// ============================================================================ -// OrbiQuant IA - Token Service -// ============================================================================ - -import jwt from 'jsonwebtoken'; -import { v4 as uuidv4 } from 'uuid'; -import crypto from 'crypto'; -import { config } from '../../../config'; -import { db } from '../../../shared/database'; -import type { - User, - AuthTokens, - JWTPayload, - JWTRefreshPayload, - Session, -} from '../types/auth.types'; - -export class TokenService { - private readonly accessTokenSecret: string; - private readonly refreshTokenSecret: string; - private readonly accessTokenExpiry: string; - private readonly refreshTokenExpiry: string; - private readonly refreshTokenExpiryMs: number; - - constructor() { - this.accessTokenSecret = config.jwt.accessSecret; - this.refreshTokenSecret = config.jwt.refreshSecret; - this.accessTokenExpiry = config.jwt.accessExpiry; - this.refreshTokenExpiry = config.jwt.refreshExpiry; - this.refreshTokenExpiryMs = this.parseExpiry(config.jwt.refreshExpiry); - } - - private parseExpiry(expiry: string): number { - const match = expiry.match(/^(\d+)([smhd])$/); - if (!match) return 7 * 24 * 60 * 60 * 1000; // default 7 days - - const value = parseInt(match[1], 10); - const unit = match[2]; - - switch (unit) { - case 's': return value * 1000; - case 'm': return value * 60 * 1000; - case 'h': return value * 60 * 60 * 1000; - case 'd': return value * 24 * 60 * 60 * 1000; - default: return 7 * 24 * 60 * 60 * 1000; - } - } - - generateAccessToken(user: User): string { - const payload: Omit = { - sub: user.id, - email: user.email, - role: user.role, - provider: user.primaryAuthProvider, - }; - - return jwt.sign(payload, this.accessTokenSecret, { - expiresIn: this.accessTokenExpiry as jwt.SignOptions['expiresIn'], - }); - } - - generateRefreshToken(userId: string, sessionId: string): string { - const payload: Omit = { - sub: userId, - sessionId, - }; - - return jwt.sign(payload, this.refreshTokenSecret, { - expiresIn: this.refreshTokenExpiry as jwt.SignOptions['expiresIn'], - }); - } - - verifyAccessToken(token: string): JWTPayload | null { - try { - return jwt.verify(token, this.accessTokenSecret) as JWTPayload; - } catch { - return null; - } - } - - verifyRefreshToken(token: string): JWTRefreshPayload | null { - try { - return jwt.verify(token, this.refreshTokenSecret) as JWTRefreshPayload; - } catch { - return null; - } - } - - async createSession( - userId: string, - userAgent?: string, - ipAddress?: string, - deviceInfo?: Record - ): Promise<{ session: Session; tokens: AuthTokens }> { - const sessionId = uuidv4(); - const refreshTokenValue = crypto.randomBytes(32).toString('hex'); - const expiresAt = new Date(Date.now() + this.refreshTokenExpiryMs); - - const result = await db.query( - `INSERT INTO sessions (id, user_id, refresh_token, user_agent, ip_address, device_info, expires_at) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING *`, - [sessionId, userId, refreshTokenValue, userAgent, ipAddress, JSON.stringify(deviceInfo), expiresAt] - ); - - const session = result.rows[0]; - - // Get user for access token - const userResult = await db.query( - 'SELECT * FROM users WHERE id = $1', - [userId] - ); - const user = userResult.rows[0]; - - const accessToken = this.generateAccessToken(user); - const refreshToken = this.generateRefreshToken(userId, sessionId); - - return { - session, - tokens: { - accessToken, - refreshToken, - expiresIn: this.parseExpiry(this.accessTokenExpiry) / 1000, - tokenType: 'Bearer', - }, - }; - } - - async refreshSession(refreshToken: string): Promise { - const decoded = this.verifyRefreshToken(refreshToken); - if (!decoded) return null; - - // Check session exists and is valid - const sessionResult = await db.query( - `SELECT * FROM sessions - WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL AND expires_at > NOW()`, - [decoded.sessionId, decoded.sub] - ); - - if (sessionResult.rows.length === 0) return null; - - // Update last active - await db.query( - 'UPDATE sessions SET last_active_at = NOW() WHERE id = $1', - [decoded.sessionId] - ); - - // Get user - const userResult = await db.query( - 'SELECT * FROM users WHERE id = $1', - [decoded.sub] - ); - - if (userResult.rows.length === 0) return null; - - const user = userResult.rows[0]; - const newAccessToken = this.generateAccessToken(user); - const newRefreshToken = this.generateRefreshToken(user.id, decoded.sessionId); - - return { - accessToken: newAccessToken, - refreshToken: newRefreshToken, - expiresIn: this.parseExpiry(this.accessTokenExpiry) / 1000, - tokenType: 'Bearer', - }; - } - - async revokeSession(sessionId: string, userId: string): Promise { - const result = await db.query( - `UPDATE sessions SET revoked_at = NOW() - WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL`, - [sessionId, userId] - ); - - return (result.rowCount ?? 0) > 0; - } - - async revokeAllUserSessions(userId: string, exceptSessionId?: string): Promise { - let query = 'UPDATE sessions SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL'; - const params: (string | undefined)[] = [userId]; - - if (exceptSessionId) { - query += ' AND id != $2'; - params.push(exceptSessionId); - } - - const result = await db.query(query, params); - return result.rowCount ?? 0; - } - - async getActiveSessions(userId: string): Promise { - const result = await db.query( - `SELECT * FROM sessions - WHERE user_id = $1 AND revoked_at IS NULL AND expires_at > NOW() - ORDER BY last_active_at DESC`, - [userId] - ); - - return result.rows; - } - - generateEmailToken(): string { - return crypto.randomBytes(32).toString('hex'); - } - - hashToken(token: string): string { - return crypto.createHash('sha256').update(token).digest('hex'); - } -} - -export const tokenService = new TokenService(); diff --git a/apps/backend/src/modules/auth/services/twofa.service.ts b/apps/backend/src/modules/auth/services/twofa.service.ts deleted file mode 100644 index b085b30..0000000 --- a/apps/backend/src/modules/auth/services/twofa.service.ts +++ /dev/null @@ -1,293 +0,0 @@ -// ============================================================================ -// OrbiQuant IA - Two-Factor Authentication Service -// ============================================================================ - -import speakeasy from 'speakeasy'; -import QRCode from 'qrcode'; -import crypto from 'crypto'; -import { db } from '../../../shared/database'; -import { logger } from '../../../shared/utils/logger'; -import type { User, TwoFactorSetupResponse } from '../types/auth.types'; - -export class TwoFactorService { - private readonly appName = 'OrbiQuant'; - - async setupTOTP(userId: string): Promise { - // Get user - const userResult = await db.query( - 'SELECT * FROM users WHERE id = $1', - [userId] - ); - - if (userResult.rows.length === 0) { - throw new Error('User not found'); - } - - const user = userResult.rows[0]; - - if (user.totpEnabled) { - throw new Error('2FA is already enabled'); - } - - // Generate secret - const secret = speakeasy.generateSecret({ - name: `${this.appName} (${user.email})`, - length: 32, - }); - - // Generate backup codes - const backupCodes = this.generateBackupCodes(); - const hashedBackupCodes = backupCodes.map((code) => - crypto.createHash('sha256').update(code).digest('hex') - ); - - // Store secret temporarily (not enabled yet) - await db.query( - `UPDATE users - SET totp_secret = $1, backup_codes = $2 - WHERE id = $3`, - [secret.base32, hashedBackupCodes, userId] - ); - - // Generate QR code - const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url!); - - return { - secret: secret.base32, - qrCodeUrl, - backupCodes, - }; - } - - async enableTOTP(userId: string, code: string): Promise<{ message: string }> { - // Get user with secret - const userResult = await db.query( - 'SELECT * FROM users WHERE id = $1', - [userId] - ); - - if (userResult.rows.length === 0) { - throw new Error('User not found'); - } - - const user = userResult.rows[0]; - - if (user.totpEnabled) { - throw new Error('2FA is already enabled'); - } - - if (!user.totpSecret) { - throw new Error('Please set up 2FA first'); - } - - // Verify code - const verified = speakeasy.totp.verify({ - secret: user.totpSecret, - encoding: 'base32', - token: code, - window: 1, - }); - - if (!verified) { - throw new Error('Invalid verification code'); - } - - // Enable 2FA - await db.query( - 'UPDATE users SET totp_enabled = TRUE WHERE id = $1', - [userId] - ); - - // Log event - await db.query( - `INSERT INTO auth_logs (user_id, event, success) - VALUES ($1, '2fa_enabled', true)`, - [userId] - ); - - logger.info('2FA enabled', { userId }); - - return { message: '2FA enabled successfully' }; - } - - async disableTOTP( - userId: string, - code: string, - _password?: string - ): Promise<{ message: string }> { - // Get user - const userResult = await db.query( - 'SELECT * FROM users WHERE id = $1', - [userId] - ); - - if (userResult.rows.length === 0) { - throw new Error('User not found'); - } - - const user = userResult.rows[0]; - - if (!user.totpEnabled) { - throw new Error('2FA is not enabled'); - } - - // Verify TOTP or backup code - const validTOTP = this.verifyTOTPCode(user.totpSecret!, code); - const validBackup = await this.verifyBackupCode(userId, code); - - if (!validTOTP && !validBackup) { - throw new Error('Invalid verification code'); - } - - // Disable 2FA - await db.query( - `UPDATE users - SET totp_enabled = FALSE, totp_secret = NULL, backup_codes = NULL - WHERE id = $1`, - [userId] - ); - - // Log event - await db.query( - `INSERT INTO auth_logs (user_id, event, success) - VALUES ($1, '2fa_disabled', true)`, - [userId] - ); - - logger.info('2FA disabled', { userId }); - - return { message: '2FA disabled successfully' }; - } - - async verifyTOTP(userId: string, code: string): Promise { - // Get user - const userResult = await db.query( - 'SELECT totp_secret, totp_enabled, backup_codes FROM users WHERE id = $1', - [userId] - ); - - if (userResult.rows.length === 0) { - return false; - } - - const user = userResult.rows[0]; - - if (!user.totpEnabled || !user.totpSecret) { - return false; - } - - // Try TOTP first - if (this.verifyTOTPCode(user.totpSecret, code)) { - // Log successful verification - await db.query( - `INSERT INTO auth_logs (user_id, event, success) - VALUES ($1, '2fa_verified', true)`, - [userId] - ); - return true; - } - - // Try backup code - if (await this.verifyBackupCode(userId, code)) { - return true; - } - - return false; - } - - private verifyTOTPCode(secret: string, code: string): boolean { - return speakeasy.totp.verify({ - secret, - encoding: 'base32', - token: code, - window: 1, // Allow 1 step tolerance (30 seconds) - }); - } - - private async verifyBackupCode(userId: string, code: string): Promise { - const userResult = await db.query<{ backup_codes: string[] }>( - 'SELECT backup_codes FROM users WHERE id = $1', - [userId] - ); - - if (userResult.rows.length === 0 || !userResult.rows[0].backup_codes) { - return false; - } - - const hashedCode = crypto.createHash('sha256').update(code).digest('hex'); - const backupCodes = userResult.rows[0].backup_codes; - const codeIndex = backupCodes.indexOf(hashedCode); - - if (codeIndex === -1) { - return false; - } - - // Remove used backup code - const updatedCodes = [...backupCodes]; - updatedCodes.splice(codeIndex, 1); - - await db.query( - 'UPDATE users SET backup_codes = $1 WHERE id = $2', - [updatedCodes, userId] - ); - - // Log backup code usage - await db.query( - `INSERT INTO auth_logs (user_id, event, success, metadata) - VALUES ($1, '2fa_verified', true, '{"method": "backup_code"}')`, - [userId] - ); - - logger.info('Backup code used', { userId, remainingCodes: updatedCodes.length }); - - return true; - } - - async regenerateBackupCodes(userId: string, code: string): Promise<{ backupCodes: string[] }> { - // Verify current 2FA - const valid = await this.verifyTOTP(userId, code); - if (!valid) { - throw new Error('Invalid verification code'); - } - - // Generate new backup codes - const backupCodes = this.generateBackupCodes(); - const hashedBackupCodes = backupCodes.map((c) => - crypto.createHash('sha256').update(c).digest('hex') - ); - - // Update in database - await db.query( - 'UPDATE users SET backup_codes = $1 WHERE id = $2', - [hashedBackupCodes, userId] - ); - - logger.info('Backup codes regenerated', { userId }); - - return { backupCodes }; - } - - private generateBackupCodes(count: number = 10): string[] { - const codes: string[] = []; - - for (let i = 0; i < count; i++) { - // Generate 8-character alphanumeric code - const code = crypto.randomBytes(4).toString('hex').toUpperCase(); - // Format as XXXX-XXXX - codes.push(`${code.slice(0, 4)}-${code.slice(4)}`); - } - - return codes; - } - - async getBackupCodesCount(userId: string): Promise { - const result = await db.query<{ backup_codes: string[] }>( - 'SELECT backup_codes FROM users WHERE id = $1', - [userId] - ); - - return result.rows[0]?.backup_codes?.length || 0; - } -} - -export const twoFactorService = new TwoFactorService(); diff --git a/apps/backend/src/modules/auth/stores/__tests__/oauth-state.store.spec.ts b/apps/backend/src/modules/auth/stores/__tests__/oauth-state.store.spec.ts deleted file mode 100644 index f2087f9..0000000 --- a/apps/backend/src/modules/auth/stores/__tests__/oauth-state.store.spec.ts +++ /dev/null @@ -1,409 +0,0 @@ -/** - * OAuth State Store Unit Tests - * - * Tests for OAuth state management including: - * - State storage and retrieval - * - State expiration - * - One-time use (getAndDelete) - * - Redis vs in-memory fallback - */ - -import { OAuthStateStore, OAuthStateData } from '../oauth-state.store'; -import { mockRedisClient, resetRedisMock } from '../../../../__tests__/mocks/redis.mock'; - -// Mock config to use in-memory store for testing -jest.mock('../../../../config', () => ({ - config: { - redis: { - // No redis config - will use in-memory fallback - }, - }, -})); - -// Mock logger -jest.mock('../../../../shared/utils/logger', () => ({ - logger: { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), - }, -})); - -describe('OAuthStateStore', () => { - let store: OAuthStateStore; - - beforeEach(() => { - resetRedisMock(); - store = new OAuthStateStore(); - }); - - describe('set', () => { - it('should store OAuth state with all properties', async () => { - const state = 'state-token-123'; - const data = { - codeVerifier: 'verifier-123', - returnUrl: 'https://example.com/callback', - provider: 'google' as const, - }; - - await store.set(state, data); - - const retrieved = await store.get(state); - expect(retrieved).toBeDefined(); - expect(retrieved?.codeVerifier).toBe('verifier-123'); - expect(retrieved?.returnUrl).toBe('https://example.com/callback'); - expect(retrieved?.provider).toBe('google'); - expect(retrieved?.createdAt).toBeDefined(); - }); - - it('should store minimal OAuth state', async () => { - const state = 'state-token-456'; - const data = { - returnUrl: 'https://example.com/dashboard', - }; - - await store.set(state, data); - - const retrieved = await store.get(state); - expect(retrieved).toBeDefined(); - expect(retrieved?.returnUrl).toBe('https://example.com/dashboard'); - expect(retrieved?.codeVerifier).toBeUndefined(); - expect(retrieved?.createdAt).toBeDefined(); - }); - - it('should set custom TTL', async () => { - const state = 'state-token-ttl'; - const data = { returnUrl: 'https://example.com' }; - const ttl = 60; // 60 seconds - - await store.set(state, data, ttl); - - const retrieved = await store.get(state); - expect(retrieved).toBeDefined(); - }); - - it('should handle storage errors gracefully', async () => { - const state = 'state-error'; - const data = { returnUrl: 'https://example.com' }; - - // Mock setex to throw error - const originalSetex = mockRedisClient.setex; - mockRedisClient.setex = jest.fn().mockRejectedValue(new Error('Storage error')); - - await expect(store.set(state, data)).rejects.toThrow('Failed to store OAuth state'); - - // Restore - mockRedisClient.setex = originalSetex; - }); - }); - - describe('get', () => { - it('should retrieve existing OAuth state', async () => { - const state = 'state-get-123'; - const data = { - codeVerifier: 'verifier-abc', - returnUrl: 'https://example.com/auth', - provider: 'facebook' as const, - }; - - await store.set(state, data); - const retrieved = await store.get(state); - - expect(retrieved).toBeDefined(); - expect(retrieved?.codeVerifier).toBe('verifier-abc'); - expect(retrieved?.provider).toBe('facebook'); - }); - - it('should return null for non-existent state', async () => { - const retrieved = await store.get('non-existent-state'); - expect(retrieved).toBeNull(); - }); - - it('should return null for expired state', async () => { - const state = 'state-expired'; - const data = { returnUrl: 'https://example.com' }; - - // Set with very short TTL - await mockRedisClient.setex('oauth:state:' + state, 0, JSON.stringify({ - ...data, - createdAt: Date.now() - 1000, - })); - - // Wait a moment for expiration - await new Promise(resolve => setTimeout(resolve, 10)); - - const retrieved = await store.get(state); - expect(retrieved).toBeNull(); - }); - - it('should handle retrieval errors gracefully', async () => { - const state = 'state-get-error'; - - // Mock get to throw error - const originalGet = mockRedisClient.get; - mockRedisClient.get = jest.fn().mockRejectedValue(new Error('Retrieval error')); - - const retrieved = await store.get(state); - expect(retrieved).toBeNull(); - - // Restore - mockRedisClient.get = originalGet; - }); - }); - - describe('delete', () => { - it('should delete existing OAuth state', async () => { - const state = 'state-delete-123'; - const data = { returnUrl: 'https://example.com' }; - - await store.set(state, data); - - // Verify exists - const before = await store.get(state); - expect(before).toBeDefined(); - - // Delete - await store.delete(state); - - // Verify deleted - const after = await store.get(state); - expect(after).toBeNull(); - }); - - it('should not throw error when deleting non-existent state', async () => { - await expect(store.delete('non-existent-state')).resolves.not.toThrow(); - }); - - it('should handle deletion errors gracefully', async () => { - const state = 'state-delete-error'; - - // Mock del to throw error - const originalDel = mockRedisClient.del; - mockRedisClient.del = jest.fn().mockRejectedValue(new Error('Deletion error')); - - await expect(store.delete(state)).resolves.not.toThrow(); - - // Restore - mockRedisClient.del = originalDel; - }); - }); - - describe('getAndDelete', () => { - it('should retrieve and delete state (one-time use)', async () => { - const state = 'state-one-time-123'; - const data = { - codeVerifier: 'verifier-one-time', - returnUrl: 'https://example.com/oauth-callback', - provider: 'github' as const, - }; - - await store.set(state, data); - - // Get and delete - const retrieved = await store.getAndDelete(state); - - expect(retrieved).toBeDefined(); - expect(retrieved?.codeVerifier).toBe('verifier-one-time'); - expect(retrieved?.provider).toBe('github'); - - // Verify it's deleted - const shouldBeNull = await store.get(state); - expect(shouldBeNull).toBeNull(); - }); - - it('should return null and not error for non-existent state', async () => { - const retrieved = await store.getAndDelete('non-existent-state'); - expect(retrieved).toBeNull(); - }); - - it('should only retrieve once (prevents replay attacks)', async () => { - const state = 'state-replay-protection'; - const data = { returnUrl: 'https://example.com' }; - - await store.set(state, data); - - // First retrieval should work - const first = await store.getAndDelete(state); - expect(first).toBeDefined(); - - // Second retrieval should return null - const second = await store.getAndDelete(state); - expect(second).toBeNull(); - }); - }); - - describe('exists', () => { - it('should return true for existing state', async () => { - const state = 'state-exists-123'; - await store.set(state, { returnUrl: 'https://example.com' }); - - const exists = await store.exists(state); - expect(exists).toBe(true); - }); - - it('should return false for non-existent state', async () => { - const exists = await store.exists('non-existent-state'); - expect(exists).toBe(false); - }); - - it('should return false for expired state', async () => { - const state = 'state-exists-expired'; - - // Set with very short TTL - await mockRedisClient.setex('oauth:state:' + state, 0, JSON.stringify({ - returnUrl: 'https://example.com', - createdAt: Date.now(), - })); - - await new Promise(resolve => setTimeout(resolve, 10)); - - const exists = await store.exists(state); - expect(exists).toBe(false); - }); - }); - - describe('getStorageType', () => { - it('should return memory for in-memory store', () => { - const type = store.getStorageType(); - expect(type).toBe('memory'); - }); - }); - - describe('State expiration', () => { - it('should automatically expire state after TTL', async () => { - const state = 'state-auto-expire'; - const data = { returnUrl: 'https://example.com' }; - - // Set with 1 second TTL - await store.set(state, data, 1); - - // Should exist immediately - const immediate = await store.get(state); - expect(immediate).toBeDefined(); - - // Wait for expiration - await new Promise(resolve => setTimeout(resolve, 1100)); - - // Should be expired - const expired = await store.get(state); - expect(expired).toBeNull(); - }); - - it('should not retrieve expired state even if get is called', async () => { - const state = 'state-no-expired-retrieval'; - const data = { returnUrl: 'https://example.com' }; - - // Manually set expired state - await mockRedisClient.setex('oauth:state:' + state, -1, JSON.stringify({ - ...data, - createdAt: Date.now() - 1000000, - })); - - const retrieved = await store.get(state); - expect(retrieved).toBeNull(); - }); - }); - - describe('Multiple providers', () => { - it('should handle states from different providers', async () => { - const states = [ - { token: 'google-state-123', data: { provider: 'google' as const, returnUrl: 'https://example.com/g' } }, - { token: 'facebook-state-456', data: { provider: 'facebook' as const, returnUrl: 'https://example.com/f' } }, - { token: 'github-state-789', data: { provider: 'github' as const, returnUrl: 'https://example.com/gh' } }, - ]; - - // Store all - for (const { token, data } of states) { - await store.set(token, data); - } - - // Retrieve all - for (const { token, data } of states) { - const retrieved = await store.get(token); - expect(retrieved?.provider).toBe(data.provider); - expect(retrieved?.returnUrl).toBe(data.returnUrl); - } - }); - - it('should keep states isolated', async () => { - await store.set('state-1', { provider: 'google' as const, returnUrl: 'url1' }); - await store.set('state-2', { provider: 'facebook' as const, returnUrl: 'url2' }); - - // Delete one - await store.delete('state-1'); - - // Other should still exist - const state2 = await store.get('state-2'); - expect(state2).toBeDefined(); - expect(state2?.provider).toBe('facebook'); - }); - }); - - describe('PKCE support', () => { - it('should store and retrieve code verifier for PKCE', async () => { - const state = 'pkce-state-123'; - const codeVerifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'; - - await store.set(state, { - codeVerifier, - returnUrl: 'https://example.com/callback', - provider: 'google' as const, - }); - - const retrieved = await store.get(state); - expect(retrieved?.codeVerifier).toBe(codeVerifier); - }); - - it('should work without code verifier (non-PKCE flows)', async () => { - const state = 'non-pkce-state-123'; - - await store.set(state, { - returnUrl: 'https://example.com/callback', - provider: 'facebook' as const, - }); - - const retrieved = await store.get(state); - expect(retrieved?.codeVerifier).toBeUndefined(); - expect(retrieved?.returnUrl).toBeDefined(); - }); - }); - - describe('Security considerations', () => { - it('should use prefixed keys to avoid collisions', async () => { - const state = 'state-123'; - await store.set(state, { returnUrl: 'https://example.com' }); - - // Check that the key in Redis has the prefix - const directGet = await mockRedisClient.get('oauth:state:' + state); - expect(directGet).toBeDefined(); - - // Without prefix should not work - const withoutPrefix = await mockRedisClient.get(state); - expect(withoutPrefix).toBeNull(); - }); - - it('should store createdAt timestamp for audit', async () => { - const state = 'state-audit-123'; - const beforeCreate = Date.now(); - - await store.set(state, { returnUrl: 'https://example.com' }); - - const afterCreate = Date.now(); - const retrieved = await store.get(state); - - expect(retrieved?.createdAt).toBeGreaterThanOrEqual(beforeCreate); - expect(retrieved?.createdAt).toBeLessThanOrEqual(afterCreate); - }); - - it('should handle malformed JSON gracefully', async () => { - const state = 'malformed-state'; - - // Manually set malformed data - await mockRedisClient.setex('oauth:state:' + state, 600, 'not-valid-json{'); - - const retrieved = await store.get(state); - expect(retrieved).toBeNull(); - }); - }); -}); diff --git a/apps/backend/src/modules/auth/stores/oauth-state.store.ts b/apps/backend/src/modules/auth/stores/oauth-state.store.ts deleted file mode 100644 index 40c3258..0000000 --- a/apps/backend/src/modules/auth/stores/oauth-state.store.ts +++ /dev/null @@ -1,239 +0,0 @@ -/** - * OAuth State Store - Redis-based storage for OAuth state - * - * @description Replaces in-memory Map storage with Redis for: - * - Scalability (works across multiple instances) - * - Persistence (survives server restarts) - * - Automatic expiration (TTL) - * - Security (state can't be enumerated) - * - * @usage - * ```typescript - * import { oauthStateStore } from '../stores/oauth-state.store'; - * - * // Store state - * await oauthStateStore.set(state, { codeVerifier, returnUrl }); - * - * // Retrieve and delete (one-time use) - * const data = await oauthStateStore.getAndDelete(state); - * ``` - * - * @migration From auth.controller.ts: - * - Remove: const oauthStates = new Map<...>(); - * - Replace: oauthStates.set(...) → await oauthStateStore.set(...) - * - Replace: oauthStates.get(...) → await oauthStateStore.get(...) - * - Replace: oauthStates.delete(...) → await oauthStateStore.delete(...) - */ - -import { config } from '../../../config'; -import { logger } from '../../../shared/utils/logger'; - -/** - * OAuth state data structure - */ -export interface OAuthStateData { - /** PKCE code verifier for providers that support it */ - codeVerifier?: string; - /** URL to redirect after authentication */ - returnUrl?: string; - /** OAuth provider (google, facebook, apple, github) */ - provider?: string; - /** Timestamp when state was created */ - createdAt: number; -} - -/** - * State store configuration - */ -const STATE_PREFIX = 'oauth:state:'; -const DEFAULT_TTL_SECONDS = 600; // 10 minutes - -/** - * Redis client interface (simplified) - * Can be ioredis or node-redis - */ -interface RedisClientLike { - get(key: string): Promise; - setex(key: string, seconds: number, value: string): Promise; - del(key: string): Promise; - quit?(): Promise; -} - -/** - * In-memory fallback store (for development/testing) - */ -class InMemoryStore { - private store = new Map(); - - async get(key: string): Promise { - const entry = this.store.get(key); - if (!entry) return null; - if (Date.now() > entry.expiresAt) { - this.store.delete(key); - return null; - } - return entry.value; - } - - async setex(key: string, seconds: number, value: string): Promise { - this.store.set(key, { - value, - expiresAt: Date.now() + seconds * 1000, - }); - } - - async del(key: string): Promise { - this.store.delete(key); - } -} - -/** - * OAuth State Store - * - * Uses Redis for production, falls back to in-memory for development. - */ -class OAuthStateStore { - private client: RedisClientLike; - private isRedis: boolean; - - constructor() { - // Initialize Redis or fallback to in-memory - if (config.redis?.url || config.redis?.host) { - this.client = this.createRedisClient(); - this.isRedis = true; - logger.info('OAuthStateStore: Using Redis backend'); - } else { - this.client = new InMemoryStore(); - this.isRedis = false; - logger.warn('OAuthStateStore: Using in-memory fallback (not recommended for production)'); - } - } - - /** - * Create Redis client based on config - */ - private createRedisClient(): RedisClientLike { - try { - // Try to use ioredis if available - // eslint-disable-next-line @typescript-eslint/no-var-requires - const Redis = require('ioredis'); - return new Redis(config.redis?.url || { - host: config.redis?.host || 'localhost', - port: config.redis?.port || 6379, - password: config.redis?.password, - db: config.redis?.db || 0, - }); - } catch { - // Fallback to node-redis - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { createClient } = require('redis'); - const client = createClient({ - url: config.redis?.url || `redis://${config.redis?.host || 'localhost'}:${config.redis?.port || 6379}`, - }); - client.connect(); - return client; - } catch { - logger.warn('No Redis client available, using in-memory store'); - return new InMemoryStore(); - } - } - } - - /** - * Store OAuth state - * - * @param state - Unique state token - * @param data - State data to store - * @param ttlSeconds - Time to live in seconds (default: 10 minutes) - */ - async set( - state: string, - data: Omit, - ttlSeconds: number = DEFAULT_TTL_SECONDS, - ): Promise { - const key = STATE_PREFIX + state; - const value = JSON.stringify({ - ...data, - createdAt: Date.now(), - }); - - try { - await this.client.setex(key, ttlSeconds, value); - } catch (error) { - logger.error('Failed to store OAuth state', { error, state: state.substring(0, 8) + '...' }); - throw new Error('Failed to store OAuth state'); - } - } - - /** - * Retrieve OAuth state - * - * @param state - State token to retrieve - * @returns State data or null if not found/expired - */ - async get(state: string): Promise { - const key = STATE_PREFIX + state; - - try { - const value = await this.client.get(key); - if (!value) return null; - return JSON.parse(value) as OAuthStateData; - } catch (error) { - logger.error('Failed to retrieve OAuth state', { error }); - return null; - } - } - - /** - * Retrieve and delete OAuth state (one-time use) - * - * @param state - State token to retrieve and delete - * @returns State data or null if not found/expired - */ - async getAndDelete(state: string): Promise { - const data = await this.get(state); - if (data) { - await this.delete(state); - } - return data; - } - - /** - * Delete OAuth state - * - * @param state - State token to delete - */ - async delete(state: string): Promise { - const key = STATE_PREFIX + state; - - try { - await this.client.del(key); - } catch (error) { - logger.error('Failed to delete OAuth state', { error }); - } - } - - /** - * Check if state exists - * - * @param state - State token to check - */ - async exists(state: string): Promise { - const data = await this.get(state); - return data !== null; - } - - /** - * Get storage type (for logging/debugging) - */ - getStorageType(): 'redis' | 'memory' { - return this.isRedis ? 'redis' : 'memory'; - } -} - -// Export singleton instance -export const oauthStateStore = new OAuthStateStore(); - -// Export class for testing -export { OAuthStateStore }; diff --git a/apps/backend/src/modules/auth/types/auth.types.ts b/apps/backend/src/modules/auth/types/auth.types.ts deleted file mode 100644 index 5a24778..0000000 --- a/apps/backend/src/modules/auth/types/auth.types.ts +++ /dev/null @@ -1,217 +0,0 @@ -// ============================================================================ -// OrbiQuant IA - Auth Types -// ============================================================================ - -export type AuthProvider = - | 'email' - | 'phone' - | 'google' - | 'facebook' - | 'twitter' - | 'apple' - | 'github'; - -export type UserRole = 'investor' | 'trader' | 'student' | 'instructor' | 'admin' | 'superadmin'; - -export enum UserRoleEnum { - INVESTOR = 'investor', - TRADER = 'trader', - STUDENT = 'student', - INSTRUCTOR = 'instructor', - ADMIN = 'admin', - SUPER_ADMIN = 'superadmin', -} - -export type UserStatus = 'pending' | 'active' | 'suspended' | 'banned'; - -export interface User { - id: string; - email: string; - emailVerified: boolean; - phone?: string; - phoneVerified: boolean; - encryptedPassword?: string; - primaryAuthProvider: AuthProvider; - totpEnabled: boolean; - totpSecret?: string; - role: UserRole; - status: UserStatus; - failedLoginAttempts: number; - lockedUntil?: Date; - lastLoginAt?: Date; - lastLoginIp?: string; - createdAt: Date; - updatedAt: Date; -} - -/** - * Authenticated user type (without password, with optional profile) - */ -export interface AuthenticatedUser extends Omit { - profile?: Profile; -} - -export interface Profile { - id: string; - userId: string; - firstName?: string; - lastName?: string; - displayName?: string; - avatarUrl?: string; - dateOfBirth?: Date; - countryCode?: string; - timezone: string; - language: string; - preferredCurrency: string; -} - -export interface OAuthAccount { - id: string; - userId: string; - provider: AuthProvider; - providerAccountId: string; - accessToken?: string; - refreshToken?: string; - tokenExpiresAt?: Date; - providerEmail?: string; - providerName?: string; - providerAvatarUrl?: string; - providerProfile?: Record; -} - -export interface Session { - id: string; - userId: string; - refreshToken: string; - userAgent?: string; - ipAddress?: string; - deviceInfo?: Record; - expiresAt: Date; - revokedAt?: Date; - createdAt: Date; - lastActiveAt: Date; -} - -// Request/Response Types -export interface RegisterEmailRequest { - email: string; - password: string; - firstName?: string; - lastName?: string; - acceptTerms: boolean; -} - -export interface LoginEmailRequest { - email: string; - password: string; - rememberMe?: boolean; - totpCode?: string; -} - -export interface LoginPhoneRequest { - phoneNumber: string; - countryCode: string; - channel?: 'sms' | 'whatsapp'; -} - -export interface VerifyPhoneOTPRequest { - phoneNumber: string; - countryCode: string; - otpCode: string; -} - -export interface OAuthCallbackData { - provider: AuthProvider; - providerAccountId: string; - email?: string; - name?: string; - avatarUrl?: string; - accessToken: string; - refreshToken?: string; - expiresAt?: Date; - profile?: Record; -} - -export interface AuthTokens { - accessToken: string; - refreshToken: string; - expiresIn: number; - tokenType: 'Bearer'; -} - -export interface AuthResponse { - user: Omit; - profile?: Profile; - tokens: AuthTokens; - requiresTwoFactor?: boolean; - isNewUser?: boolean; -} - -export interface TwoFactorSetupResponse { - secret: string; - qrCodeUrl: string; - backupCodes: string[]; -} - -export interface RefreshTokenRequest { - refreshToken: string; -} - -export interface ForgotPasswordRequest { - email: string; -} - -export interface ResetPasswordRequest { - token: string; - password: string; -} - -export interface ChangePasswordRequest { - currentPassword: string; - newPassword: string; -} - -export interface VerifyEmailRequest { - token: string; -} - -export interface Enable2FARequest { - totpCode: string; -} - -export interface Verify2FARequest { - totpCode: string; -} - -// OAuth Provider Configs -export interface OAuthProviderConfig { - clientId: string; - clientSecret: string; - callbackUrl: string; - scope: string[]; -} - -export interface OAuthProviderConfigs { - google: OAuthProviderConfig; - facebook: OAuthProviderConfig; - twitter: OAuthProviderConfig & { consumerKey: string; consumerSecret: string }; - apple: OAuthProviderConfig & { teamId: string; keyId: string; privateKey: string }; - github: OAuthProviderConfig; -} - -// JWT Payload -export interface JWTPayload { - sub: string; // user id - email: string; - role: UserRole; - provider: AuthProvider; - iat: number; - exp: number; -} - -export interface JWTRefreshPayload { - sub: string; - sessionId: string; - iat: number; - exp: number; -} diff --git a/apps/backend/src/modules/auth/validators/auth.validators.ts b/apps/backend/src/modules/auth/validators/auth.validators.ts deleted file mode 100644 index cc18bd1..0000000 --- a/apps/backend/src/modules/auth/validators/auth.validators.ts +++ /dev/null @@ -1,159 +0,0 @@ -// ============================================================================ -// OrbiQuant IA - Auth Validators -// ============================================================================ - -import { body, param } from 'express-validator'; - -export const registerValidator = [ - body('email') - .isEmail() - .normalizeEmail() - .withMessage('Please provide a valid email'), - body('password') - .isLength({ min: 8 }) - .withMessage('Password must be at least 8 characters') - .matches(/[A-Z]/) - .withMessage('Password must contain at least one uppercase letter') - .matches(/[a-z]/) - .withMessage('Password must contain at least one lowercase letter') - .matches(/[0-9]/) - .withMessage('Password must contain at least one number') - .matches(/[!@#$%^&*(),.?":{}|<>]/) - .withMessage('Password must contain at least one special character'), - body('firstName') - .optional() - .trim() - .isLength({ min: 1, max: 100 }) - .withMessage('First name must be between 1 and 100 characters'), - body('lastName') - .optional() - .trim() - .isLength({ min: 1, max: 100 }) - .withMessage('Last name must be between 1 and 100 characters'), - body('acceptTerms') - .isBoolean() - .equals('true') - .withMessage('You must accept the terms and conditions'), -]; - -export const loginValidator = [ - body('email') - .isEmail() - .normalizeEmail() - .withMessage('Please provide a valid email'), - body('password') - .notEmpty() - .withMessage('Password is required'), - body('totpCode') - .optional() - .isLength({ min: 6, max: 6 }) - .withMessage('TOTP code must be 6 digits'), - body('rememberMe') - .optional() - .isBoolean(), -]; - -export const emailValidator = [ - body('email') - .isEmail() - .normalizeEmail() - .withMessage('Please provide a valid email'), -]; - -export const tokenValidator = [ - body('token') - .notEmpty() - .isLength({ min: 32 }) - .withMessage('Invalid token'), -]; - -export const resetPasswordValidator = [ - body('token') - .notEmpty() - .isLength({ min: 32 }) - .withMessage('Invalid token'), - body('password') - .isLength({ min: 8 }) - .withMessage('Password must be at least 8 characters') - .matches(/[A-Z]/) - .withMessage('Password must contain at least one uppercase letter') - .matches(/[a-z]/) - .withMessage('Password must contain at least one lowercase letter') - .matches(/[0-9]/) - .withMessage('Password must contain at least one number') - .matches(/[!@#$%^&*(),.?":{}|<>]/) - .withMessage('Password must contain at least one special character'), -]; - -export const changePasswordValidator = [ - body('currentPassword') - .notEmpty() - .withMessage('Current password is required'), - body('newPassword') - .isLength({ min: 8 }) - .withMessage('Password must be at least 8 characters') - .matches(/[A-Z]/) - .withMessage('Password must contain at least one uppercase letter') - .matches(/[a-z]/) - .withMessage('Password must contain at least one lowercase letter') - .matches(/[0-9]/) - .withMessage('Password must contain at least one number') - .matches(/[!@#$%^&*(),.?":{}|<>]/) - .withMessage('Password must contain at least one special character'), -]; - -export const phoneOTPValidator = [ - body('phoneNumber') - .notEmpty() - .matches(/^[0-9+\-\s()]+$/) - .withMessage('Please provide a valid phone number'), - body('countryCode') - .notEmpty() - .matches(/^[0-9]{1,4}$/) - .withMessage('Please provide a valid country code'), - body('channel') - .optional() - .isIn(['sms', 'whatsapp', 'call']) - .withMessage('Invalid channel'), -]; - -export const verifyPhoneOTPValidator = [ - body('phoneNumber') - .notEmpty() - .matches(/^[0-9+\-\s()]+$/) - .withMessage('Please provide a valid phone number'), - body('countryCode') - .notEmpty() - .matches(/^[0-9]{1,4}$/) - .withMessage('Please provide a valid country code'), - body('otpCode') - .notEmpty() - .isLength({ min: 6, max: 6 }) - .matches(/^[0-9]+$/) - .withMessage('OTP code must be 6 digits'), -]; - -export const oauthProviderValidator = [ - param('provider') - .isIn(['google', 'facebook', 'twitter', 'apple', 'github']) - .withMessage('Invalid OAuth provider'), -]; - -export const refreshTokenValidator = [ - body('refreshToken') - .notEmpty() - .withMessage('Refresh token is required'), -]; - -export const totpCodeValidator = [ - body('code') - .notEmpty() - .matches(/^[0-9A-Z-]{6,10}$/) - .withMessage('Invalid verification code'), -]; - -export const sessionIdValidator = [ - param('sessionId') - .isUUID() - .withMessage('Invalid session ID'), -]; diff --git a/apps/backend/src/modules/education/controllers/education.controller.ts b/apps/backend/src/modules/education/controllers/education.controller.ts deleted file mode 100644 index 06a01b8..0000000 --- a/apps/backend/src/modules/education/controllers/education.controller.ts +++ /dev/null @@ -1,675 +0,0 @@ -/** - * Education Controller - * Handles education-related endpoints - */ - -import { Request, Response, NextFunction } from 'express'; -import { courseService } from '../services/course.service'; -import { enrollmentService } from '../services/enrollment.service'; -import type { CourseFilters, PaginationOptions } from '../types/education.types'; - -type AuthRequest = Request; - -// ============================================================================ -// Categories -// ============================================================================ - -export async function getCategories(req: Request, res: Response, next: NextFunction): Promise { - try { - const categories = await courseService.getCategories(); - res.json({ - success: true, - data: categories, - }); - } catch (error) { - next(error); - } -} - -export async function createCategory(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const { name, slug, description, icon, parentId, sortOrder } = req.body; - - if (!name) { - res.status(400).json({ - success: false, - error: { message: 'Category name is required', code: 'VALIDATION_ERROR' }, - }); - return; - } - - const category = await courseService.createCategory({ - name, - slug, - description, - icon, - parentId, - sortOrder, - }); - - res.status(201).json({ - success: true, - data: category, - }); - } catch (error) { - next(error); - } -} - -// ============================================================================ -// Courses -// ============================================================================ - -export async function getCourses(req: Request, res: Response, next: NextFunction): Promise { - try { - const filters: CourseFilters = { - categoryId: req.query.categoryId as string, - level: req.query.level as CourseFilters['level'], - status: (req.query.status as CourseFilters['status']) || 'published', - isFree: req.query.isFree === 'true' ? true : req.query.isFree === 'false' ? false : undefined, - search: req.query.search as string, - minRating: req.query.minRating ? parseFloat(req.query.minRating as string) : undefined, - }; - - const pagination: PaginationOptions = { - page: parseInt(req.query.page as string, 10) || 1, - pageSize: Math.min(parseInt(req.query.pageSize as string, 10) || 20, 100), - sortBy: req.query.sortBy as string, - sortOrder: req.query.sortOrder as 'asc' | 'desc', - }; - - const result = await courseService.getCourses(filters, pagination); - - res.json({ - success: true, - ...result, - }); - } catch (error) { - next(error); - } -} - -export async function getCourseById(req: Request, res: Response, next: NextFunction): Promise { - try { - const { courseId } = req.params; - - const course = await courseService.getCourseWithDetails(courseId); - if (!course) { - res.status(404).json({ - success: false, - error: { message: 'Course not found', code: 'NOT_FOUND' }, - }); - return; - } - - res.json({ - success: true, - data: course, - }); - } catch (error) { - next(error); - } -} - -export async function getCourseBySlug(req: Request, res: Response, next: NextFunction): Promise { - try { - const { slug } = req.params; - - const course = await courseService.getCourseBySlug(slug); - if (!course) { - res.status(404).json({ - success: false, - error: { message: 'Course not found', code: 'NOT_FOUND' }, - }); - return; - } - - const courseWithDetails = await courseService.getCourseWithDetails(course.id); - - res.json({ - success: true, - data: courseWithDetails, - }); - } catch (error) { - next(error); - } -} - -export async function createCourse(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const { - title, slug, description, shortDescription, thumbnailUrl, previewVideoUrl, - categoryId, level, tags, isFree, price, currency, requiresSubscription, - minSubscriptionTier, durationMinutes, - } = req.body; - - if (!title) { - res.status(400).json({ - success: false, - error: { message: 'Course title is required', code: 'VALIDATION_ERROR' }, - }); - return; - } - - const course = await courseService.createCourse({ - title, - slug, - description, - shortDescription, - thumbnailUrl, - previewVideoUrl, - categoryId, - level, - tags, - isFree, - price, - currency, - requiresSubscription, - minSubscriptionTier, - durationMinutes, - instructorId: userId, - }); - - res.status(201).json({ - success: true, - data: course, - }); - } catch (error) { - next(error); - } -} - -export async function updateCourse(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const { courseId } = req.params; - const updates = req.body; - - const course = await courseService.updateCourse(courseId, updates); - if (!course) { - res.status(404).json({ - success: false, - error: { message: 'Course not found', code: 'NOT_FOUND' }, - }); - return; - } - - res.json({ - success: true, - data: course, - }); - } catch (error) { - next(error); - } -} - -export async function deleteCourse(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const { courseId } = req.params; - - const deleted = await courseService.deleteCourse(courseId); - if (!deleted) { - res.status(404).json({ - success: false, - error: { message: 'Course not found', code: 'NOT_FOUND' }, - }); - return; - } - - res.json({ - success: true, - message: 'Course deleted successfully', - }); - } catch (error) { - next(error); - } -} - -export async function publishCourse(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const { courseId } = req.params; - - const course = await courseService.publishCourse(courseId); - if (!course) { - res.status(404).json({ - success: false, - error: { message: 'Course not found', code: 'NOT_FOUND' }, - }); - return; - } - - res.json({ - success: true, - data: course, - message: 'Course published successfully', - }); - } catch (error) { - next(error); - } -} - -export async function getPopularCourses(req: Request, res: Response, next: NextFunction): Promise { - try { - const limit = Math.min(parseInt(req.query.limit as string, 10) || 10, 50); - const courses = await courseService.getPopularCourses(limit); - - res.json({ - success: true, - data: courses, - }); - } catch (error) { - next(error); - } -} - -export async function getNewCourses(req: Request, res: Response, next: NextFunction): Promise { - try { - const limit = Math.min(parseInt(req.query.limit as string, 10) || 10, 50); - const courses = await courseService.getNewCourses(limit); - - res.json({ - success: true, - data: courses, - }); - } catch (error) { - next(error); - } -} - -// ============================================================================ -// Modules -// ============================================================================ - -export async function getCourseModules(req: Request, res: Response, next: NextFunction): Promise { - try { - const { courseId } = req.params; - - const modules = await courseService.getCourseModules(courseId); - - res.json({ - success: true, - data: modules, - }); - } catch (error) { - next(error); - } -} - -export async function createModule(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const { courseId } = req.params; - const { title, description, sortOrder, unlockAfterModuleId } = req.body; - - if (!title) { - res.status(400).json({ - success: false, - error: { message: 'Module title is required', code: 'VALIDATION_ERROR' }, - }); - return; - } - - const module = await courseService.createModule({ - courseId, - title, - description, - sortOrder, - unlockAfterModuleId, - }); - - res.status(201).json({ - success: true, - data: module, - }); - } catch (error) { - next(error); - } -} - -export async function deleteModule(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const { moduleId } = req.params; - - const deleted = await courseService.deleteModule(moduleId); - if (!deleted) { - res.status(404).json({ - success: false, - error: { message: 'Module not found', code: 'NOT_FOUND' }, - }); - return; - } - - res.json({ - success: true, - message: 'Module deleted successfully', - }); - } catch (error) { - next(error); - } -} - -// ============================================================================ -// Lessons -// ============================================================================ - -export async function getModuleLessons(req: Request, res: Response, next: NextFunction): Promise { - try { - const { moduleId } = req.params; - - const lessons = await courseService.getModuleLessons(moduleId); - - res.json({ - success: true, - data: lessons, - }); - } catch (error) { - next(error); - } -} - -export async function getLessonById(req: Request, res: Response, next: NextFunction): Promise { - try { - const { lessonId } = req.params; - - const lesson = await courseService.getLessonById(lessonId); - if (!lesson) { - res.status(404).json({ - success: false, - error: { message: 'Lesson not found', code: 'NOT_FOUND' }, - }); - return; - } - - res.json({ - success: true, - data: lesson, - }); - } catch (error) { - next(error); - } -} - -export async function createLesson(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const { moduleId } = req.params; - const { - courseId, title, slug, contentType, videoUrl, videoDurationSeconds, - videoProvider, contentMarkdown, resources, sortOrder, isPreview, - } = req.body; - - if (!title || !courseId) { - res.status(400).json({ - success: false, - error: { message: 'Title and courseId are required', code: 'VALIDATION_ERROR' }, - }); - return; - } - - const lesson = await courseService.createLesson({ - moduleId, - courseId, - title, - slug, - contentType, - videoUrl, - videoDurationSeconds, - videoProvider, - contentMarkdown, - resources, - sortOrder, - isPreview, - }); - - res.status(201).json({ - success: true, - data: lesson, - }); - } catch (error) { - next(error); - } -} - -export async function deleteLesson(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const { lessonId } = req.params; - - const deleted = await courseService.deleteLesson(lessonId); - if (!deleted) { - res.status(404).json({ - success: false, - error: { message: 'Lesson not found', code: 'NOT_FOUND' }, - }); - return; - } - - res.json({ - success: true, - message: 'Lesson deleted successfully', - }); - } catch (error) { - next(error); - } -} - -// ============================================================================ -// Enrollments -// ============================================================================ - -export async function getMyEnrollments(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const enrollments = await enrollmentService.getUserEnrollments(userId); - - res.json({ - success: true, - data: enrollments, - }); - } catch (error) { - next(error); - } -} - -export async function enrollInCourse(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const { courseId } = req.params; - - // Check if course exists and is free or user has purchased - const course = await courseService.getCourseById(courseId); - if (!course) { - res.status(404).json({ - success: false, - error: { message: 'Course not found', code: 'NOT_FOUND' }, - }); - return; - } - - if (!course.isFree) { - res.status(403).json({ - success: false, - error: { message: 'Payment required for this course', code: 'PAYMENT_REQUIRED' }, - }); - return; - } - - const enrollment = await enrollmentService.createEnrollment({ - userId, - courseId, - }); - - res.status(201).json({ - success: true, - data: enrollment, - message: 'Successfully enrolled in course', - }); - } catch (error) { - if ((error as Error).message === 'Already enrolled in this course') { - res.status(409).json({ - success: false, - error: { message: 'Already enrolled in this course', code: 'ALREADY_ENROLLED' }, - }); - return; - } - next(error); - } -} - -export async function getEnrollmentStatus(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const { courseId } = req.params; - - const enrollment = await enrollmentService.getEnrollment(userId, courseId); - const progress = enrollment - ? await enrollmentService.getCourseProgress(userId, courseId) - : []; - - res.json({ - success: true, - data: { - enrolled: !!enrollment, - enrollment, - lessonProgress: progress, - }, - }); - } catch (error) { - next(error); - } -} - -// ============================================================================ -// Progress -// ============================================================================ - -export async function updateLessonProgress(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const { lessonId } = req.params; - const { videoWatchedSeconds, videoCompleted, userNotes } = req.body; - - const progress = await enrollmentService.updateLessonProgress(userId, lessonId, { - videoWatchedSeconds, - videoCompleted, - userNotes, - }); - - res.json({ - success: true, - data: progress, - }); - } catch (error) { - if ((error as Error).message === 'Not enrolled in this course') { - res.status(403).json({ - success: false, - error: { message: 'You must be enrolled to track progress', code: 'NOT_ENROLLED' }, - }); - return; - } - next(error); - } -} - -export async function markLessonComplete(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const { lessonId } = req.params; - - const progress = await enrollmentService.markLessonComplete(userId, lessonId); - - res.json({ - success: true, - data: progress, - message: 'Lesson marked as complete', - }); - } catch (error) { - next(error); - } -} - -// ============================================================================ -// User Stats -// ============================================================================ - -export async function getMyLearningStats(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const stats = await enrollmentService.getUserLearningStats(userId); - - res.json({ - success: true, - data: stats, - }); - } catch (error) { - next(error); - } -} - -export async function getCourseStats(req: Request, res: Response, next: NextFunction): Promise { - try { - const { courseId } = req.params; - - const stats = await enrollmentService.getCourseEnrollmentStats(courseId); - - res.json({ - success: true, - data: stats, - }); - } catch (error) { - next(error); - } -} diff --git a/apps/backend/src/modules/education/education.routes.ts b/apps/backend/src/modules/education/education.routes.ts deleted file mode 100644 index efe1d11..0000000 --- a/apps/backend/src/modules/education/education.routes.ts +++ /dev/null @@ -1,182 +0,0 @@ -/** - * Education Routes - * Course, lesson, and enrollment management - */ - -import { Router, RequestHandler } from 'express'; -import * as educationController from './controllers/education.controller'; -import { requireAuth } from '../../core/guards/auth.guard'; - -const router = Router(); - -// Type cast helper for authenticated routes -// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -const authHandler = (fn: Function): RequestHandler => fn as RequestHandler; - -// ============================================================================ -// Public Routes -// ============================================================================ - -/** - * GET /api/v1/education/categories - * Get all course categories - */ -router.get('/categories', educationController.getCategories); - -/** - * GET /api/v1/education/courses - * List published courses with filters and pagination - * Query params: categoryId, level, isFree, search, minRating, page, pageSize, sortBy, sortOrder - */ -router.get('/courses', educationController.getCourses); - -/** - * GET /api/v1/education/courses/popular - * Get popular courses - * Query params: limit - */ -router.get('/courses/popular', educationController.getPopularCourses); - -/** - * GET /api/v1/education/courses/new - * Get newest courses - * Query params: limit - */ -router.get('/courses/new', educationController.getNewCourses); - -/** - * GET /api/v1/education/courses/:courseId - * Get course by ID with full details - */ -router.get('/courses/:courseId', educationController.getCourseById); - -/** - * GET /api/v1/education/courses/slug/:slug - * Get course by slug with full details - */ -router.get('/courses/slug/:slug', educationController.getCourseBySlug); - -/** - * GET /api/v1/education/courses/:courseId/modules - * Get course modules with lessons - */ -router.get('/courses/:courseId/modules', educationController.getCourseModules); - -/** - * GET /api/v1/education/courses/:courseId/stats - * Get course enrollment statistics - */ -router.get('/courses/:courseId/stats', educationController.getCourseStats); - -/** - * GET /api/v1/education/modules/:moduleId/lessons - * Get module lessons - */ -router.get('/modules/:moduleId/lessons', educationController.getModuleLessons); - -/** - * GET /api/v1/education/lessons/:lessonId - * Get lesson by ID - */ -router.get('/lessons/:lessonId', educationController.getLessonById); - -// ============================================================================ -// Authenticated User Routes -// ============================================================================ - -/** - * GET /api/v1/education/my/enrollments - * Get current user's course enrollments - */ -router.get('/my/enrollments', authHandler(requireAuth), authHandler(educationController.getMyEnrollments)); - -/** - * GET /api/v1/education/my/stats - * Get current user's learning statistics - */ -router.get('/my/stats', authHandler(requireAuth), authHandler(educationController.getMyLearningStats)); - -/** - * POST /api/v1/education/courses/:courseId/enroll - * Enroll in a free course - */ -router.post('/courses/:courseId/enroll', authHandler(requireAuth), authHandler(educationController.enrollInCourse)); - -/** - * GET /api/v1/education/courses/:courseId/enrollment - * Get enrollment status for a course - */ -router.get('/courses/:courseId/enrollment', authHandler(requireAuth), authHandler(educationController.getEnrollmentStatus)); - -/** - * POST /api/v1/education/lessons/:lessonId/progress - * Update lesson progress - * Body: { videoWatchedSeconds?, videoCompleted?, userNotes? } - */ -router.post('/lessons/:lessonId/progress', authHandler(requireAuth), authHandler(educationController.updateLessonProgress)); - -/** - * POST /api/v1/education/lessons/:lessonId/complete - * Mark lesson as complete - */ -router.post('/lessons/:lessonId/complete', authHandler(requireAuth), authHandler(educationController.markLessonComplete)); - -// ============================================================================ -// Instructor/Admin Routes (Course Management) -// ============================================================================ - -/** - * POST /api/v1/education/categories - * Create a new category (admin only) - */ -router.post('/categories', authHandler(requireAuth), authHandler(educationController.createCategory)); - -/** - * POST /api/v1/education/courses - * Create a new course (instructor/admin) - */ -router.post('/courses', authHandler(requireAuth), authHandler(educationController.createCourse)); - -/** - * PATCH /api/v1/education/courses/:courseId - * Update course details - */ -router.patch('/courses/:courseId', authHandler(requireAuth), authHandler(educationController.updateCourse)); - -/** - * DELETE /api/v1/education/courses/:courseId - * Delete a course - */ -router.delete('/courses/:courseId', authHandler(requireAuth), authHandler(educationController.deleteCourse)); - -/** - * POST /api/v1/education/courses/:courseId/publish - * Publish a course - */ -router.post('/courses/:courseId/publish', authHandler(requireAuth), authHandler(educationController.publishCourse)); - -/** - * POST /api/v1/education/courses/:courseId/modules - * Create a module in a course - */ -router.post('/courses/:courseId/modules', authHandler(requireAuth), authHandler(educationController.createModule)); - -/** - * DELETE /api/v1/education/modules/:moduleId - * Delete a module - */ -router.delete('/modules/:moduleId', authHandler(requireAuth), authHandler(educationController.deleteModule)); - -/** - * POST /api/v1/education/modules/:moduleId/lessons - * Create a lesson in a module - */ -router.post('/modules/:moduleId/lessons', authHandler(requireAuth), authHandler(educationController.createLesson)); - -/** - * DELETE /api/v1/education/lessons/:lessonId - * Delete a lesson - */ -router.delete('/lessons/:lessonId', authHandler(requireAuth), authHandler(educationController.deleteLesson)); - -export { router as educationRouter }; diff --git a/apps/backend/src/modules/education/services/course.service.ts b/apps/backend/src/modules/education/services/course.service.ts deleted file mode 100644 index 0f17dc8..0000000 --- a/apps/backend/src/modules/education/services/course.service.ts +++ /dev/null @@ -1,568 +0,0 @@ -/** - * Course Service - * Handles course management operations - */ - -import { db } from '../../../shared/database'; -import { logger } from '../../../shared/utils/logger'; -import type { - Course, - CourseWithDetails, - CreateCourseInput, - UpdateCourseInput, - CourseFilters, - Category, - CreateCategoryInput, - Module, - ModuleWithLessons, - CreateModuleInput, - Lesson, - CreateLessonInput, - PaginatedResult, - PaginationOptions, -} from '../types/education.types'; - -// ============================================================================ -// Helper Functions -// ============================================================================ - -function generateSlug(title: string): string { - return title - .toLowerCase() - .replace(/[^a-z0-9\s-]/g, '') - .replace(/\s+/g, '-') - .replace(/-+/g, '-') - .trim(); -} - -function transformCourse(row: Record): Course { - return { - id: row.id as string, - title: row.title as string, - slug: row.slug as string, - description: row.description as string | undefined, - shortDescription: row.short_description as string | undefined, - thumbnailUrl: row.thumbnail_url as string | undefined, - previewVideoUrl: row.preview_video_url as string | undefined, - categoryId: row.category_id as string | undefined, - level: row.level as Course['level'], - tags: (row.tags as string[]) || [], - isFree: row.is_free as boolean, - price: parseFloat(row.price as string) || 0, - currency: row.currency as string, - requiresSubscription: row.requires_subscription as boolean, - minSubscriptionTier: row.min_subscription_tier as string | undefined, - durationMinutes: row.duration_minutes as number | undefined, - lessonsCount: row.lessons_count as number, - enrolledCount: row.enrolled_count as number, - averageRating: parseFloat(row.average_rating as string) || 0, - ratingsCount: row.ratings_count as number, - status: row.status as Course['status'], - publishedAt: row.published_at ? new Date(row.published_at as string) : undefined, - instructorId: row.instructor_id as string | undefined, - aiGenerated: row.ai_generated as boolean, - createdAt: new Date(row.created_at as string), - updatedAt: new Date(row.updated_at as string), - }; -} - -function transformLesson(row: Record): Lesson { - return { - id: row.id as string, - moduleId: row.module_id as string, - courseId: row.course_id as string, - title: row.title as string, - slug: row.slug as string, - contentType: row.content_type as Lesson['contentType'], - videoUrl: row.video_url as string | undefined, - videoDurationSeconds: row.video_duration_seconds as number | undefined, - videoProvider: row.video_provider as string | undefined, - contentMarkdown: row.content_markdown as string | undefined, - contentHtml: row.content_html as string | undefined, - resources: (row.resources as Lesson['resources']) || [], - sortOrder: row.sort_order as number, - isPreview: row.is_preview as boolean, - aiGenerated: row.ai_generated as boolean, - aiSummary: row.ai_summary as string | undefined, - createdAt: new Date(row.created_at as string), - updatedAt: new Date(row.updated_at as string), - }; -} - -// ============================================================================ -// Course Service Class -// ============================================================================ - -class CourseService { - // ========================================================================== - // Categories - // ========================================================================== - - async getCategories(): Promise { - const result = await db.query>( - `SELECT * FROM education.categories ORDER BY sort_order, name` - ); - return result.rows.map((row) => ({ - id: row.id as string, - name: row.name as string, - slug: row.slug as string, - description: row.description as string | undefined, - icon: row.icon as string | undefined, - parentId: row.parent_id as string | undefined, - sortOrder: row.sort_order as number, - createdAt: new Date(row.created_at as string), - updatedAt: new Date(row.updated_at as string), - })); - } - - async getCategoryById(id: string): Promise { - const result = await db.query>( - `SELECT * FROM education.categories WHERE id = $1`, - [id] - ); - if (result.rows.length === 0) return null; - const row = result.rows[0]; - return { - id: row.id as string, - name: row.name as string, - slug: row.slug as string, - description: row.description as string | undefined, - icon: row.icon as string | undefined, - parentId: row.parent_id as string | undefined, - sortOrder: row.sort_order as number, - createdAt: new Date(row.created_at as string), - updatedAt: new Date(row.updated_at as string), - }; - } - - async createCategory(input: CreateCategoryInput): Promise { - const slug = input.slug || generateSlug(input.name); - const result = await db.query>( - `INSERT INTO education.categories (name, slug, description, icon, parent_id, sort_order) - VALUES ($1, $2, $3, $4, $5, $6) - RETURNING *`, - [input.name, slug, input.description, input.icon, input.parentId, input.sortOrder || 0] - ); - const row = result.rows[0]; - return { - id: row.id as string, - name: row.name as string, - slug: row.slug as string, - description: row.description as string | undefined, - icon: row.icon as string | undefined, - parentId: row.parent_id as string | undefined, - sortOrder: row.sort_order as number, - createdAt: new Date(row.created_at as string), - updatedAt: new Date(row.updated_at as string), - }; - } - - // ========================================================================== - // Courses - // ========================================================================== - - async getCourses( - filters: CourseFilters = {}, - pagination: PaginationOptions = {} - ): Promise> { - const { page = 1, pageSize = 20, sortBy = 'created_at', sortOrder = 'desc' } = pagination; - const offset = (page - 1) * pageSize; - - const conditions: string[] = []; - const params: (string | number | boolean | null)[] = []; - let paramIndex = 1; - - if (filters.categoryId) { - conditions.push(`category_id = $${paramIndex++}`); - params.push(filters.categoryId); - } - if (filters.level) { - conditions.push(`level = $${paramIndex++}`); - params.push(filters.level); - } - if (filters.status) { - conditions.push(`status = $${paramIndex++}`); - params.push(filters.status); - } - if (filters.isFree !== undefined) { - conditions.push(`is_free = $${paramIndex++}`); - params.push(filters.isFree); - } - if (filters.instructorId) { - conditions.push(`instructor_id = $${paramIndex++}`); - params.push(filters.instructorId); - } - if (filters.search) { - conditions.push(`(title ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`); - params.push(`%${filters.search}%`); - paramIndex++; - } - if (filters.minRating) { - conditions.push(`average_rating >= $${paramIndex++}`); - params.push(filters.minRating); - } - - const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; - const allowedSortColumns = ['created_at', 'title', 'price', 'average_rating', 'enrolled_count']; - const safeSort = allowedSortColumns.includes(sortBy) ? sortBy : 'created_at'; - const safeSortOrder = sortOrder === 'asc' ? 'ASC' : 'DESC'; - - const countResult = await db.query<{ count: string }>( - `SELECT COUNT(*) as count FROM education.courses ${whereClause}`, - params - ); - const total = parseInt(countResult.rows[0].count, 10); - - params.push(pageSize, offset); - const dataResult = await db.query>( - `SELECT * FROM education.courses ${whereClause} - ORDER BY ${safeSort} ${safeSortOrder} - LIMIT $${paramIndex++} OFFSET $${paramIndex}`, - params - ); - - return { - data: dataResult.rows.map(transformCourse), - total, - page, - pageSize, - totalPages: Math.ceil(total / pageSize), - }; - } - - async getCourseById(id: string): Promise { - const result = await db.query>( - `SELECT * FROM education.courses WHERE id = $1`, - [id] - ); - if (result.rows.length === 0) return null; - return transformCourse(result.rows[0]); - } - - async getCourseBySlug(slug: string): Promise { - const result = await db.query>( - `SELECT * FROM education.courses WHERE slug = $1`, - [slug] - ); - if (result.rows.length === 0) return null; - return transformCourse(result.rows[0]); - } - - async getCourseWithDetails(id: string): Promise { - const course = await this.getCourseById(id); - if (!course) return null; - - // Get category - let category: Category | undefined; - if (course.categoryId) { - category = (await this.getCategoryById(course.categoryId)) || undefined; - } - - // Get modules with lessons - const modules = await this.getCourseModules(id); - - return { - ...course, - category, - modules, - }; - } - - async createCourse(input: CreateCourseInput): Promise { - const slug = input.slug || generateSlug(input.title); - - const result = await db.query>( - `INSERT INTO education.courses ( - title, slug, description, short_description, thumbnail_url, preview_video_url, - category_id, level, tags, is_free, price, currency, requires_subscription, - min_subscription_tier, duration_minutes, instructor_id, status - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, 'draft') - RETURNING *`, - [ - input.title, - slug, - input.description, - input.shortDescription, - input.thumbnailUrl, - input.previewVideoUrl, - input.categoryId, - input.level || 'beginner', - input.tags || [], - input.isFree ?? false, - input.price || 0, - input.currency || 'USD', - input.requiresSubscription ?? false, - input.minSubscriptionTier, - input.durationMinutes, - input.instructorId, - ] - ); - - logger.info('[CourseService] Course created:', { courseId: result.rows[0].id, title: input.title }); - return transformCourse(result.rows[0]); - } - - async updateCourse(id: string, input: UpdateCourseInput): Promise { - const updates: string[] = []; - const params: (string | number | boolean | string[] | null)[] = []; - let paramIndex = 1; - - if (input.title !== undefined) { - updates.push(`title = $${paramIndex++}`); - params.push(input.title); - } - if (input.slug !== undefined) { - updates.push(`slug = $${paramIndex++}`); - params.push(input.slug); - } - if (input.description !== undefined) { - updates.push(`description = $${paramIndex++}`); - params.push(input.description); - } - if (input.shortDescription !== undefined) { - updates.push(`short_description = $${paramIndex++}`); - params.push(input.shortDescription); - } - if (input.thumbnailUrl !== undefined) { - updates.push(`thumbnail_url = $${paramIndex++}`); - params.push(input.thumbnailUrl); - } - if (input.categoryId !== undefined) { - updates.push(`category_id = $${paramIndex++}`); - params.push(input.categoryId); - } - if (input.level !== undefined) { - updates.push(`level = $${paramIndex++}`); - params.push(input.level); - } - if (input.tags !== undefined) { - updates.push(`tags = $${paramIndex++}`); - params.push(input.tags); - } - if (input.isFree !== undefined) { - updates.push(`is_free = $${paramIndex++}`); - params.push(input.isFree); - } - if (input.price !== undefined) { - updates.push(`price = $${paramIndex++}`); - params.push(input.price); - } - if (input.status !== undefined) { - updates.push(`status = $${paramIndex++}`); - params.push(input.status); - if (input.status === 'published') { - updates.push(`published_at = CURRENT_TIMESTAMP`); - } - } - - if (updates.length === 0) return this.getCourseById(id); - - params.push(id); - const result = await db.query>( - `UPDATE education.courses SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING *`, - params - ); - - if (result.rows.length === 0) return null; - logger.info('[CourseService] Course updated:', { courseId: id }); - return transformCourse(result.rows[0]); - } - - async deleteCourse(id: string): Promise { - const result = await db.query(`DELETE FROM education.courses WHERE id = $1`, [id]); - logger.info('[CourseService] Course deleted:', { courseId: id }); - return (result.rowCount ?? 0) > 0; - } - - async publishCourse(id: string): Promise { - return this.updateCourse(id, { status: 'published' }); - } - - async archiveCourse(id: string): Promise { - return this.updateCourse(id, { status: 'archived' }); - } - - // ========================================================================== - // Modules - // ========================================================================== - - async getCourseModules(courseId: string): Promise { - const modulesResult = await db.query>( - `SELECT * FROM education.modules WHERE course_id = $1 ORDER BY sort_order`, - [courseId] - ); - - const modules: ModuleWithLessons[] = []; - for (const row of modulesResult.rows) { - const lessons = await this.getModuleLessons(row.id as string); - modules.push({ - id: row.id as string, - courseId: row.course_id as string, - title: row.title as string, - description: row.description as string | undefined, - sortOrder: row.sort_order as number, - unlockAfterModuleId: row.unlock_after_module_id as string | undefined, - createdAt: new Date(row.created_at as string), - updatedAt: new Date(row.updated_at as string), - lessons, - }); - } - - return modules; - } - - async getModuleById(id: string): Promise { - const result = await db.query>( - `SELECT * FROM education.modules WHERE id = $1`, - [id] - ); - if (result.rows.length === 0) return null; - const row = result.rows[0]; - return { - id: row.id as string, - courseId: row.course_id as string, - title: row.title as string, - description: row.description as string | undefined, - sortOrder: row.sort_order as number, - unlockAfterModuleId: row.unlock_after_module_id as string | undefined, - createdAt: new Date(row.created_at as string), - updatedAt: new Date(row.updated_at as string), - }; - } - - async createModule(input: CreateModuleInput): Promise { - const result = await db.query>( - `INSERT INTO education.modules (course_id, title, description, sort_order, unlock_after_module_id) - VALUES ($1, $2, $3, $4, $5) - RETURNING *`, - [input.courseId, input.title, input.description, input.sortOrder || 0, input.unlockAfterModuleId] - ); - const row = result.rows[0]; - return { - id: row.id as string, - courseId: row.course_id as string, - title: row.title as string, - description: row.description as string | undefined, - sortOrder: row.sort_order as number, - unlockAfterModuleId: row.unlock_after_module_id as string | undefined, - createdAt: new Date(row.created_at as string), - updatedAt: new Date(row.updated_at as string), - }; - } - - async deleteModule(id: string): Promise { - const result = await db.query(`DELETE FROM education.modules WHERE id = $1`, [id]); - return (result.rowCount ?? 0) > 0; - } - - // ========================================================================== - // Lessons - // ========================================================================== - - async getModuleLessons(moduleId: string): Promise { - const result = await db.query>( - `SELECT * FROM education.lessons WHERE module_id = $1 ORDER BY sort_order`, - [moduleId] - ); - return result.rows.map(transformLesson); - } - - async getLessonById(id: string): Promise { - const result = await db.query>( - `SELECT * FROM education.lessons WHERE id = $1`, - [id] - ); - if (result.rows.length === 0) return null; - return transformLesson(result.rows[0]); - } - - async createLesson(input: CreateLessonInput): Promise { - const slug = input.slug || generateSlug(input.title); - - const result = await db.query>( - `INSERT INTO education.lessons ( - module_id, course_id, title, slug, content_type, video_url, - video_duration_seconds, video_provider, content_markdown, - resources, sort_order, is_preview - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) - RETURNING *`, - [ - input.moduleId, - input.courseId, - input.title, - slug, - input.contentType || 'video', - input.videoUrl, - input.videoDurationSeconds, - input.videoProvider, - input.contentMarkdown, - JSON.stringify(input.resources || []), - input.sortOrder || 0, - input.isPreview ?? false, - ] - ); - - // Update course lessons count - await db.query( - `UPDATE education.courses SET lessons_count = lessons_count + 1 WHERE id = $1`, - [input.courseId] - ); - - return transformLesson(result.rows[0]); - } - - async deleteLesson(id: string): Promise { - // Get lesson to update course count - const lesson = await this.getLessonById(id); - if (!lesson) return false; - - const result = await db.query(`DELETE FROM education.lessons WHERE id = $1`, [id]); - - if ((result.rowCount ?? 0) > 0) { - await db.query( - `UPDATE education.courses SET lessons_count = GREATEST(lessons_count - 1, 0) WHERE id = $1`, - [lesson.courseId] - ); - } - - return (result.rowCount ?? 0) > 0; - } - - // ========================================================================== - // Statistics - // ========================================================================== - - async updateCourseStats(courseId: string): Promise { - await db.query( - `UPDATE education.courses - SET lessons_count = (SELECT COUNT(*) FROM education.lessons WHERE course_id = $1), - duration_minutes = (SELECT COALESCE(SUM(video_duration_seconds) / 60, 0) FROM education.lessons WHERE course_id = $1) - WHERE id = $1`, - [courseId] - ); - } - - async getPopularCourses(limit: number = 10): Promise { - const result = await db.query>( - `SELECT * FROM education.courses - WHERE status = 'published' - ORDER BY enrolled_count DESC, average_rating DESC - LIMIT $1`, - [limit] - ); - return result.rows.map(transformCourse); - } - - async getNewCourses(limit: number = 10): Promise { - const result = await db.query>( - `SELECT * FROM education.courses - WHERE status = 'published' - ORDER BY published_at DESC - LIMIT $1`, - [limit] - ); - return result.rows.map(transformCourse); - } -} - -export const courseService = new CourseService(); diff --git a/apps/backend/src/modules/education/services/enrollment.service.ts b/apps/backend/src/modules/education/services/enrollment.service.ts deleted file mode 100644 index ad9dd43..0000000 --- a/apps/backend/src/modules/education/services/enrollment.service.ts +++ /dev/null @@ -1,420 +0,0 @@ -/** - * Enrollment Service - * Handles course enrollments and progress tracking - */ - -import { db } from '../../../shared/database'; -import { logger } from '../../../shared/utils/logger'; -import { courseService } from './course.service'; -import type { - Enrollment, - EnrollmentWithCourse, - CreateEnrollmentInput, - LessonProgress, - UpdateLessonProgressInput, - Course, -} from '../types/education.types'; - -// ============================================================================ -// Helper Functions -// ============================================================================ - -function transformEnrollment(row: Record): Enrollment { - return { - id: row.id as string, - userId: row.user_id as string, - courseId: row.course_id as string, - status: row.status as Enrollment['status'], - progressPercentage: parseFloat(row.progress_percentage as string) || 0, - lessonsCompleted: row.lessons_completed as number, - enrolledAt: new Date(row.enrolled_at as string), - expiresAt: row.expires_at ? new Date(row.expires_at as string) : undefined, - completedAt: row.completed_at ? new Date(row.completed_at as string) : undefined, - paymentId: row.payment_id as string | undefined, - certificateIssued: row.certificate_issued as boolean, - certificateUrl: row.certificate_url as string | undefined, - certificateIssuedAt: row.certificate_issued_at ? new Date(row.certificate_issued_at as string) : undefined, - createdAt: new Date(row.created_at as string), - updatedAt: new Date(row.updated_at as string), - }; -} - -function transformLessonProgress(row: Record): LessonProgress { - return { - id: row.id as string, - userId: row.user_id as string, - lessonId: row.lesson_id as string, - enrollmentId: row.enrollment_id as string, - videoWatchedSeconds: row.video_watched_seconds as number, - videoCompleted: row.video_completed as boolean, - startedAt: row.started_at ? new Date(row.started_at as string) : undefined, - completedAt: row.completed_at ? new Date(row.completed_at as string) : undefined, - userNotes: row.user_notes as string | undefined, - createdAt: new Date(row.created_at as string), - updatedAt: new Date(row.updated_at as string), - }; -} - -// ============================================================================ -// Enrollment Service Class -// ============================================================================ - -class EnrollmentService { - // ========================================================================== - // Enrollments - // ========================================================================== - - async getUserEnrollments(userId: string): Promise { - const result = await db.query>( - `SELECT e.*, c.title, c.slug, c.thumbnail_url, c.level, c.lessons_count, - c.duration_minutes, c.instructor_id - FROM education.enrollments e - JOIN education.courses c ON e.course_id = c.id - WHERE e.user_id = $1 - ORDER BY e.enrolled_at DESC`, - [userId] - ); - - return result.rows.map((row) => ({ - ...transformEnrollment(row), - course: { - id: row.course_id as string, - title: row.title as string, - slug: row.slug as string, - thumbnailUrl: row.thumbnail_url as string | undefined, - level: row.level as Course['level'], - lessonsCount: row.lessons_count as number, - durationMinutes: row.duration_minutes as number | undefined, - instructorId: row.instructor_id as string | undefined, - } as Course, - })); - } - - async getEnrollment(userId: string, courseId: string): Promise { - const result = await db.query>( - `SELECT * FROM education.enrollments WHERE user_id = $1 AND course_id = $2`, - [userId, courseId] - ); - if (result.rows.length === 0) return null; - return transformEnrollment(result.rows[0]); - } - - async getEnrollmentById(id: string): Promise { - const result = await db.query>( - `SELECT * FROM education.enrollments WHERE id = $1`, - [id] - ); - if (result.rows.length === 0) return null; - return transformEnrollment(result.rows[0]); - } - - async isEnrolled(userId: string, courseId: string): Promise { - const result = await db.query<{ exists: boolean }>( - `SELECT EXISTS( - SELECT 1 FROM education.enrollments - WHERE user_id = $1 AND course_id = $2 AND status = 'active' - ) as exists`, - [userId, courseId] - ); - return result.rows[0].exists; - } - - async createEnrollment(input: CreateEnrollmentInput): Promise { - // Check if already enrolled - const existing = await this.getEnrollment(input.userId, input.courseId); - if (existing) { - if (existing.status === 'active') { - throw new Error('Already enrolled in this course'); - } - // Reactivate expired/cancelled enrollment - const result = await db.query>( - `UPDATE education.enrollments - SET status = 'active', expires_at = $1 - WHERE id = $2 - RETURNING *`, - [input.expiresAt, existing.id] - ); - return transformEnrollment(result.rows[0]); - } - - // Check if course exists - const course = await courseService.getCourseById(input.courseId); - if (!course) { - throw new Error('Course not found'); - } - - // Create enrollment - const result = await db.query>( - `INSERT INTO education.enrollments (user_id, course_id, payment_id, expires_at) - VALUES ($1, $2, $3, $4) - RETURNING *`, - [input.userId, input.courseId, input.paymentId, input.expiresAt] - ); - - // Update course enrolled count - await db.query( - `UPDATE education.courses SET enrolled_count = enrolled_count + 1 WHERE id = $1`, - [input.courseId] - ); - - logger.info('[EnrollmentService] User enrolled in course:', { - userId: input.userId, - courseId: input.courseId, - }); - - return transformEnrollment(result.rows[0]); - } - - async cancelEnrollment(userId: string, courseId: string): Promise { - const result = await db.query( - `UPDATE education.enrollments SET status = 'cancelled' WHERE user_id = $1 AND course_id = $2`, - [userId, courseId] - ); - - if ((result.rowCount ?? 0) > 0) { - await db.query( - `UPDATE education.courses SET enrolled_count = GREATEST(enrolled_count - 1, 0) WHERE id = $1`, - [courseId] - ); - } - - return (result.rowCount ?? 0) > 0; - } - - // ========================================================================== - // Progress Tracking - // ========================================================================== - - async getLessonProgress(userId: string, lessonId: string): Promise { - const result = await db.query>( - `SELECT * FROM education.lesson_progress WHERE user_id = $1 AND lesson_id = $2`, - [userId, lessonId] - ); - if (result.rows.length === 0) return null; - return transformLessonProgress(result.rows[0]); - } - - async getCourseProgress(userId: string, courseId: string): Promise { - const result = await db.query>( - `SELECT lp.* FROM education.lesson_progress lp - JOIN education.lessons l ON lp.lesson_id = l.id - WHERE lp.user_id = $1 AND l.course_id = $2`, - [userId, courseId] - ); - return result.rows.map(transformLessonProgress); - } - - async updateLessonProgress( - userId: string, - lessonId: string, - input: UpdateLessonProgressInput - ): Promise { - // Get lesson and enrollment - const lesson = await courseService.getLessonById(lessonId); - if (!lesson) { - throw new Error('Lesson not found'); - } - - const enrollment = await this.getEnrollment(userId, lesson.courseId); - if (!enrollment) { - throw new Error('Not enrolled in this course'); - } - - // Check if progress exists - const existing = await this.getLessonProgress(userId, lessonId); - - if (existing) { - // Update existing progress - const updates: string[] = []; - const params: (string | number | boolean | null)[] = []; - let paramIndex = 1; - - if (input.videoWatchedSeconds !== undefined) { - updates.push(`video_watched_seconds = GREATEST(video_watched_seconds, $${paramIndex++})`); - params.push(input.videoWatchedSeconds); - } - if (input.videoCompleted !== undefined && input.videoCompleted) { - updates.push(`video_completed = true`); - updates.push(`completed_at = COALESCE(completed_at, CURRENT_TIMESTAMP)`); - } - if (input.userNotes !== undefined) { - updates.push(`user_notes = $${paramIndex++}`); - params.push(input.userNotes); - } - - params.push(userId, lessonId); - const result = await db.query>( - `UPDATE education.lesson_progress - SET ${updates.join(', ')} - WHERE user_id = $${paramIndex++} AND lesson_id = $${paramIndex} - RETURNING *`, - params - ); - - // Update enrollment progress if lesson completed - if (input.videoCompleted && !existing.videoCompleted) { - await this.updateEnrollmentProgress(enrollment.id, lesson.courseId); - } - - return transformLessonProgress(result.rows[0]); - } else { - // Create new progress record - const result = await db.query>( - `INSERT INTO education.lesson_progress ( - user_id, lesson_id, enrollment_id, video_watched_seconds, video_completed, - started_at, completed_at, user_notes - ) VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP, $6, $7) - RETURNING *`, - [ - userId, - lessonId, - enrollment.id, - input.videoWatchedSeconds || 0, - input.videoCompleted || false, - input.videoCompleted ? new Date() : null, - input.userNotes, - ] - ); - - if (input.videoCompleted) { - await this.updateEnrollmentProgress(enrollment.id, lesson.courseId); - } - - return transformLessonProgress(result.rows[0]); - } - } - - async markLessonComplete(userId: string, lessonId: string): Promise { - return this.updateLessonProgress(userId, lessonId, { videoCompleted: true }); - } - - private async updateEnrollmentProgress(enrollmentId: string, courseId: string): Promise { - // Calculate progress - const statsResult = await db.query<{ total: string; completed: string }>( - `SELECT - (SELECT COUNT(*) FROM education.lessons WHERE course_id = $1) as total, - (SELECT COUNT(*) FROM education.lesson_progress lp - JOIN education.lessons l ON lp.lesson_id = l.id - WHERE lp.enrollment_id = $2 AND lp.video_completed = true) as completed`, - [courseId, enrollmentId] - ); - - const total = parseInt(statsResult.rows[0].total, 10); - const completed = parseInt(statsResult.rows[0].completed, 10); - const progress = total > 0 ? (completed / total) * 100 : 0; - - // Update enrollment - const isCompleted = progress >= 100; - await db.query( - `UPDATE education.enrollments - SET progress_percentage = $1, - lessons_completed = $2, - status = CASE WHEN $3 THEN 'completed' ELSE status END, - completed_at = CASE WHEN $3 AND completed_at IS NULL THEN CURRENT_TIMESTAMP ELSE completed_at END - WHERE id = $4`, - [progress, completed, isCompleted, enrollmentId] - ); - - if (isCompleted) { - logger.info('[EnrollmentService] Course completed:', { enrollmentId, courseId }); - } - } - - // ========================================================================== - // Certificates - // ========================================================================== - - async issueCertificate(enrollmentId: string): Promise { - const enrollment = await this.getEnrollmentById(enrollmentId); - if (!enrollment) return null; - if (enrollment.status !== 'completed') { - throw new Error('Course must be completed to issue certificate'); - } - - // Generate certificate URL (placeholder - would integrate with PDF generator) - const certificateUrl = `/api/v1/education/certificates/${enrollmentId}`; - - const result = await db.query>( - `UPDATE education.enrollments - SET certificate_issued = true, - certificate_url = $1, - certificate_issued_at = CURRENT_TIMESTAMP - WHERE id = $2 - RETURNING *`, - [certificateUrl, enrollmentId] - ); - - logger.info('[EnrollmentService] Certificate issued:', { enrollmentId }); - return transformEnrollment(result.rows[0]); - } - - // ========================================================================== - // Analytics - // ========================================================================== - - async getCourseEnrollmentStats(courseId: string): Promise<{ - totalEnrolled: number; - activeEnrollments: number; - completedEnrollments: number; - averageProgress: number; - certificatesIssued: number; - }> { - const result = await db.query>( - `SELECT - COUNT(*) as total_enrolled, - COUNT(*) FILTER (WHERE status = 'active') as active_enrollments, - COUNT(*) FILTER (WHERE status = 'completed') as completed_enrollments, - COALESCE(AVG(progress_percentage), 0) as average_progress, - COUNT(*) FILTER (WHERE certificate_issued = true) as certificates_issued - FROM education.enrollments - WHERE course_id = $1`, - [courseId] - ); - - const stats = result.rows[0]; - return { - totalEnrolled: parseInt(stats.total_enrolled, 10), - activeEnrollments: parseInt(stats.active_enrollments, 10), - completedEnrollments: parseInt(stats.completed_enrollments, 10), - averageProgress: parseFloat(stats.average_progress) || 0, - certificatesIssued: parseInt(stats.certificates_issued, 10), - }; - } - - async getUserLearningStats(userId: string): Promise<{ - totalCourses: number; - completedCourses: number; - totalLessonsCompleted: number; - totalMinutesWatched: number; - certificatesEarned: number; - }> { - const enrollmentResult = await db.query>( - `SELECT - COUNT(*) as total_courses, - COUNT(*) FILTER (WHERE status = 'completed') as completed_courses, - SUM(lessons_completed) as total_lessons_completed, - COUNT(*) FILTER (WHERE certificate_issued = true) as certificates_earned - FROM education.enrollments - WHERE user_id = $1`, - [userId] - ); - - const progressResult = await db.query<{ total_seconds: string }>( - `SELECT COALESCE(SUM(video_watched_seconds), 0) as total_seconds - FROM education.lesson_progress - WHERE user_id = $1`, - [userId] - ); - - const stats = enrollmentResult.rows[0]; - return { - totalCourses: parseInt(stats.total_courses, 10), - completedCourses: parseInt(stats.completed_courses, 10), - totalLessonsCompleted: parseInt(stats.total_lessons_completed, 10) || 0, - totalMinutesWatched: Math.floor(parseInt(progressResult.rows[0].total_seconds, 10) / 60), - certificatesEarned: parseInt(stats.certificates_earned, 10), - }; - } -} - -export const enrollmentService = new EnrollmentService(); diff --git a/apps/backend/src/modules/education/types/education.types.ts b/apps/backend/src/modules/education/types/education.types.ts deleted file mode 100644 index 6e5c6c3..0000000 --- a/apps/backend/src/modules/education/types/education.types.ts +++ /dev/null @@ -1,401 +0,0 @@ -/** - * Education Module Types - */ - -// ============================================================================ -// Enums -// ============================================================================ - -export type CourseLevel = 'beginner' | 'intermediate' | 'advanced' | 'expert'; -export type CourseStatus = 'draft' | 'published' | 'archived'; -export type ContentType = 'video' | 'text' | 'quiz' | 'exercise' | 'resource'; -export type EnrollmentStatus = 'active' | 'completed' | 'expired' | 'cancelled'; -export type QuestionType = 'multiple_choice' | 'true_false' | 'multiple_answer' | 'short_answer'; - -// ============================================================================ -// Category -// ============================================================================ - -export interface Category { - id: string; - name: string; - slug: string; - description?: string; - icon?: string; - parentId?: string; - sortOrder: number; - createdAt: Date; - updatedAt: Date; -} - -export interface CreateCategoryInput { - name: string; - slug?: string; - description?: string; - icon?: string; - parentId?: string; - sortOrder?: number; -} - -// ============================================================================ -// Course -// ============================================================================ - -export interface Course { - id: string; - title: string; - slug: string; - description?: string; - shortDescription?: string; - thumbnailUrl?: string; - previewVideoUrl?: string; - categoryId?: string; - level: CourseLevel; - tags: string[]; - isFree: boolean; - price: number; - currency: string; - requiresSubscription: boolean; - minSubscriptionTier?: string; - durationMinutes?: number; - lessonsCount: number; - enrolledCount: number; - averageRating: number; - ratingsCount: number; - status: CourseStatus; - publishedAt?: Date; - instructorId?: string; - aiGenerated: boolean; - createdAt: Date; - updatedAt: Date; -} - -export interface CourseWithDetails extends Course { - category?: Category; - instructor?: { - id: string; - email: string; - profile?: { - firstName?: string; - lastName?: string; - avatarUrl?: string; - }; - }; - modules?: ModuleWithLessons[]; -} - -export interface CreateCourseInput { - title: string; - slug?: string; - description?: string; - shortDescription?: string; - thumbnailUrl?: string; - previewVideoUrl?: string; - categoryId?: string; - level?: CourseLevel; - tags?: string[]; - isFree?: boolean; - price?: number; - currency?: string; - requiresSubscription?: boolean; - minSubscriptionTier?: string; - durationMinutes?: number; - instructorId?: string; -} - -export interface UpdateCourseInput extends Partial { - status?: CourseStatus; -} - -export interface CourseFilters { - categoryId?: string; - level?: CourseLevel; - status?: CourseStatus; - isFree?: boolean; - instructorId?: string; - search?: string; - minRating?: number; - tags?: string[]; -} - -// ============================================================================ -// Module -// ============================================================================ - -export interface Module { - id: string; - courseId: string; - title: string; - description?: string; - sortOrder: number; - unlockAfterModuleId?: string; - createdAt: Date; - updatedAt: Date; -} - -export interface ModuleWithLessons extends Module { - lessons: Lesson[]; -} - -export interface CreateModuleInput { - courseId: string; - title: string; - description?: string; - sortOrder?: number; - unlockAfterModuleId?: string; -} - -// ============================================================================ -// Lesson -// ============================================================================ - -export interface LessonResource { - name: string; - url: string; - type: string; -} - -export interface Lesson { - id: string; - moduleId: string; - courseId: string; - title: string; - slug: string; - contentType: ContentType; - videoUrl?: string; - videoDurationSeconds?: number; - videoProvider?: string; - contentMarkdown?: string; - contentHtml?: string; - resources: LessonResource[]; - sortOrder: number; - isPreview: boolean; - aiGenerated: boolean; - aiSummary?: string; - createdAt: Date; - updatedAt: Date; -} - -export interface LessonWithProgress extends Lesson { - progress?: LessonProgress; -} - -export interface CreateLessonInput { - moduleId: string; - courseId: string; - title: string; - slug?: string; - contentType?: ContentType; - videoUrl?: string; - videoDurationSeconds?: number; - videoProvider?: string; - contentMarkdown?: string; - resources?: LessonResource[]; - sortOrder?: number; - isPreview?: boolean; -} - -// ============================================================================ -// Quiz -// ============================================================================ - -export interface QuizOption { - id: string; - text: string; - isCorrect: boolean; -} - -export interface QuizQuestion { - id: string; - quizId: string; - questionType: QuestionType; - questionText: string; - explanation?: string; - options?: QuizOption[]; - correctAnswers?: string[]; - points: number; - sortOrder: number; - createdAt: Date; -} - -export interface Quiz { - id: string; - lessonId?: string; - courseId: string; - title: string; - description?: string; - passingScore: number; - maxAttempts?: number; - timeLimitMinutes?: number; - shuffleQuestions: boolean; - showCorrectAnswers: boolean; - aiGenerated: boolean; - createdAt: Date; - updatedAt: Date; -} - -export interface QuizWithQuestions extends Quiz { - questions: QuizQuestion[]; -} - -export interface CreateQuizInput { - lessonId?: string; - courseId: string; - title: string; - description?: string; - passingScore?: number; - maxAttempts?: number; - timeLimitMinutes?: number; - shuffleQuestions?: boolean; - showCorrectAnswers?: boolean; -} - -export interface CreateQuizQuestionInput { - quizId: string; - questionType?: QuestionType; - questionText: string; - explanation?: string; - options?: Omit[]; - correctAnswers?: string[]; - points?: number; - sortOrder?: number; -} - -// ============================================================================ -// Enrollment -// ============================================================================ - -export interface Enrollment { - id: string; - userId: string; - courseId: string; - status: EnrollmentStatus; - progressPercentage: number; - lessonsCompleted: number; - enrolledAt: Date; - expiresAt?: Date; - completedAt?: Date; - paymentId?: string; - certificateIssued: boolean; - certificateUrl?: string; - certificateIssuedAt?: Date; - createdAt: Date; - updatedAt: Date; -} - -export interface EnrollmentWithCourse extends Enrollment { - course: Course; -} - -export interface CreateEnrollmentInput { - userId: string; - courseId: string; - paymentId?: string; - expiresAt?: Date; -} - -// ============================================================================ -// Lesson Progress -// ============================================================================ - -export interface LessonProgress { - id: string; - userId: string; - lessonId: string; - enrollmentId: string; - videoWatchedSeconds: number; - videoCompleted: boolean; - startedAt?: Date; - completedAt?: Date; - userNotes?: string; - createdAt: Date; - updatedAt: Date; -} - -export interface UpdateLessonProgressInput { - videoWatchedSeconds?: number; - videoCompleted?: boolean; - userNotes?: string; -} - -// ============================================================================ -// Quiz Attempts -// ============================================================================ - -export interface QuizAnswer { - questionId: string; - answer: string | string[]; - isCorrect: boolean; -} - -export interface QuizAttempt { - id: string; - userId: string; - quizId: string; - enrollmentId?: string; - score: number; - passed: boolean; - answers: QuizAnswer[]; - startedAt: Date; - submittedAt?: Date; - timeSpentSeconds?: number; - createdAt: Date; -} - -export interface SubmitQuizInput { - quizId: string; - answers: { questionId: string; answer: string | string[] }[]; -} - -// ============================================================================ -// Reviews -// ============================================================================ - -export interface CourseReview { - id: string; - userId: string; - courseId: string; - rating: number; - reviewText?: string; - isApproved: boolean; - isFeatured: boolean; - createdAt: Date; - updatedAt: Date; -} - -export interface CourseReviewWithUser extends CourseReview { - user: { - id: string; - email: string; - profile?: { - firstName?: string; - lastName?: string; - avatarUrl?: string; - }; - }; -} - -export interface CreateReviewInput { - courseId: string; - rating: number; - reviewText?: string; -} - -// ============================================================================ -// Pagination -// ============================================================================ - -export interface PaginatedResult { - data: T[]; - total: number; - page: number; - pageSize: number; - totalPages: number; -} - -export interface PaginationOptions { - page?: number; - pageSize?: number; - sortBy?: string; - sortOrder?: 'asc' | 'desc'; -} diff --git a/apps/backend/src/modules/investment/controllers/investment.controller.ts b/apps/backend/src/modules/investment/controllers/investment.controller.ts deleted file mode 100644 index 7f68e12..0000000 --- a/apps/backend/src/modules/investment/controllers/investment.controller.ts +++ /dev/null @@ -1,530 +0,0 @@ -/** - * Investment Controller - * Handles investment-related endpoints - */ - -import { Request, Response, NextFunction } from 'express'; -import { productService, RiskProfile } from '../services/product.service'; -import { accountService, CreateAccountInput } from '../services/account.service'; -import { - transactionService, - TransactionType, - TransactionStatus, - WithdrawalStatus, -} from '../services/transaction.service'; - -// ============================================================================ -// Types -// ============================================================================ - -// Use Request directly - user is already declared globally in auth.middleware.ts -type AuthRequest = Request; - -// ============================================================================ -// Product Controllers -// ============================================================================ - -/** - * Get all investment products - */ -export async function getProducts(req: Request, res: Response, next: NextFunction): Promise { - try { - const { riskProfile } = req.query; - - let products; - if (riskProfile) { - products = await productService.getProductsByRiskProfile(riskProfile as RiskProfile); - } else { - products = await productService.getProducts(); - } - - res.json({ - success: true, - data: products, - }); - } catch (error) { - next(error); - } -} - -/** - * Get product by ID - */ -export async function getProductById(req: Request, res: Response, next: NextFunction): Promise { - try { - const { productId } = req.params; - - const product = await productService.getProductById(productId); - if (!product) { - res.status(404).json({ - success: false, - error: { message: 'Product not found', code: 'NOT_FOUND' }, - }); - return; - } - - // Get additional stats - const stats = await productService.getProductStats(productId); - - res.json({ - success: true, - data: { ...product, stats }, - }); - } catch (error) { - next(error); - } -} - -/** - * Get product performance - */ -export async function getProductPerformance(req: Request, res: Response, next: NextFunction): Promise { - try { - const { productId } = req.params; - const { period = 'month' } = req.query; - - const performance = await productService.getProductPerformance( - productId, - period as 'week' | 'month' | '3months' | 'year' - ); - - res.json({ - success: true, - data: performance, - }); - } catch (error) { - next(error); - } -} - -// ============================================================================ -// Account Controllers -// ============================================================================ - -/** - * Get user accounts - */ -export async function getUserAccounts(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const accounts = await accountService.getUserAccounts(userId); - - res.json({ - success: true, - data: accounts, - }); - } catch (error) { - next(error); - } -} - -/** - * Get account summary - */ -export async function getAccountSummary(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const summary = await accountService.getAccountSummary(userId); - - res.json({ - success: true, - data: summary, - }); - } catch (error) { - next(error); - } -} - -/** - * Get account by ID - */ -export async function getAccountById(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const { accountId } = req.params; - - const account = await accountService.getAccountById(accountId); - if (!account) { - res.status(404).json({ - success: false, - error: { message: 'Account not found', code: 'NOT_FOUND' }, - }); - return; - } - - if (account.userId !== userId) { - res.status(403).json({ - success: false, - error: { message: 'Forbidden', code: 'FORBIDDEN' }, - }); - return; - } - - // Get performance history - const performance = await accountService.getAccountPerformance(accountId, 30); - - res.json({ - success: true, - data: { ...account, performance }, - }); - } catch (error) { - next(error); - } -} - -/** - * Create investment account - */ -export async function createAccount(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const { productId, initialDeposit } = req.body; - - if (!productId || !initialDeposit) { - res.status(400).json({ - success: false, - error: { message: 'Product ID and initial deposit are required', code: 'VALIDATION_ERROR' }, - }); - return; - } - - const input: CreateAccountInput = { - userId, - productId, - initialDeposit: Number(initialDeposit), - }; - - const account = await accountService.createAccount(input); - - res.status(201).json({ - success: true, - data: account, - }); - } catch (error) { - next(error); - } -} - -/** - * Close account - */ -export async function closeAccount(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const { accountId } = req.params; - - const account = await accountService.getAccountById(accountId); - if (!account) { - res.status(404).json({ - success: false, - error: { message: 'Account not found', code: 'NOT_FOUND' }, - }); - return; - } - - if (account.userId !== userId) { - res.status(403).json({ - success: false, - error: { message: 'Forbidden', code: 'FORBIDDEN' }, - }); - return; - } - - const closedAccount = await accountService.closeAccount(accountId); - - res.json({ - success: true, - data: closedAccount, - }); - } catch (error) { - next(error); - } -} - -// ============================================================================ -// Transaction Controllers -// ============================================================================ - -/** - * Get account transactions - */ -export async function getTransactions(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const { accountId } = req.params; - const { type, status, limit = 50, offset = 0 } = req.query; - - const account = await accountService.getAccountById(accountId); - if (!account) { - res.status(404).json({ - success: false, - error: { message: 'Account not found', code: 'NOT_FOUND' }, - }); - return; - } - - if (account.userId !== userId) { - res.status(403).json({ - success: false, - error: { message: 'Forbidden', code: 'FORBIDDEN' }, - }); - return; - } - - const { transactions, total } = await transactionService.getAccountTransactions(accountId, { - type: type as TransactionType | undefined, - status: status as TransactionStatus | undefined, - limit: Number(limit), - offset: Number(offset), - }); - - res.json({ - success: true, - data: transactions, - pagination: { - total, - limit: Number(limit), - offset: Number(offset), - }, - }); - } catch (error) { - next(error); - } -} - -/** - * Create deposit - */ -export async function createDeposit(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const { accountId } = req.params; - const { amount } = req.body; - - const account = await accountService.getAccountById(accountId); - if (!account) { - res.status(404).json({ - success: false, - error: { message: 'Account not found', code: 'NOT_FOUND' }, - }); - return; - } - - if (account.userId !== userId) { - res.status(403).json({ - success: false, - error: { message: 'Forbidden', code: 'FORBIDDEN' }, - }); - return; - } - - if (!amount || amount <= 0) { - res.status(400).json({ - success: false, - error: { message: 'Valid amount is required', code: 'VALIDATION_ERROR' }, - }); - return; - } - - const transaction = await transactionService.createDeposit({ - accountId, - amount: Number(amount), - }); - - res.status(201).json({ - success: true, - data: transaction, - }); - } catch (error) { - next(error); - } -} - -/** - * Create withdrawal request - */ -export async function createWithdrawal(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const { accountId } = req.params; - const { amount, bankInfo, cryptoInfo } = req.body; - - const account = await accountService.getAccountById(accountId); - if (!account) { - res.status(404).json({ - success: false, - error: { message: 'Account not found', code: 'NOT_FOUND' }, - }); - return; - } - - if (account.userId !== userId) { - res.status(403).json({ - success: false, - error: { message: 'Forbidden', code: 'FORBIDDEN' }, - }); - return; - } - - if (!amount || amount <= 0) { - res.status(400).json({ - success: false, - error: { message: 'Valid amount is required', code: 'VALIDATION_ERROR' }, - }); - return; - } - - const withdrawal = await transactionService.createWithdrawal(userId, { - accountId, - amount: Number(amount), - bankInfo, - cryptoInfo, - }); - - res.status(201).json({ - success: true, - data: withdrawal, - message: 'Withdrawal request submitted. Processing time: 72 hours.', - }); - } catch (error) { - next(error); - } -} - -/** - * Get user withdrawals - */ -export async function getWithdrawals(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const { status } = req.query; - - const withdrawals = await transactionService.getUserWithdrawals( - userId, - status as WithdrawalStatus | undefined - ); - - res.json({ - success: true, - data: withdrawals, - }); - } catch (error) { - next(error); - } -} - -/** - * Get account distributions - */ -export async function getDistributions(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const { accountId } = req.params; - - const account = await accountService.getAccountById(accountId); - if (!account) { - res.status(404).json({ - success: false, - error: { message: 'Account not found', code: 'NOT_FOUND' }, - }); - return; - } - - if (account.userId !== userId) { - res.status(403).json({ - success: false, - error: { message: 'Forbidden', code: 'FORBIDDEN' }, - }); - return; - } - - const distributions = await transactionService.getAccountDistributions(accountId); - - res.json({ - success: true, - data: distributions, - }); - } catch (error) { - next(error); - } -} diff --git a/apps/backend/src/modules/investment/investment.routes.ts b/apps/backend/src/modules/investment/investment.routes.ts deleted file mode 100644 index f39c21b..0000000 --- a/apps/backend/src/modules/investment/investment.routes.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * Investment Routes - * Products, accounts, and transaction endpoints - */ - -import { Router, RequestHandler } from 'express'; -import * as investmentController from './controllers/investment.controller'; - -const router = Router(); - -// Type cast helper for authenticated routes -// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -const authHandler = (fn: Function): RequestHandler => fn as RequestHandler; - -// ============================================================================ -// Product Routes (Public) -// ============================================================================ - -/** - * GET /api/v1/investment/products - * Get all investment products - * Query params: riskProfile - */ -router.get('/products', investmentController.getProducts); - -/** - * GET /api/v1/investment/products/:productId - * Get product details with stats - */ -router.get('/products/:productId', investmentController.getProductById); - -/** - * GET /api/v1/investment/products/:productId/performance - * Get product performance history - * Query params: period (week, month, 3months, year) - */ -router.get('/products/:productId/performance', investmentController.getProductPerformance); - -// ============================================================================ -// Account Routes (Authenticated) -// TODO: Add authentication middleware -// ============================================================================ - -/** - * GET /api/v1/investment/accounts - * Get user's investment accounts - */ -router.get('/accounts', authHandler(investmentController.getUserAccounts)); - -/** - * GET /api/v1/investment/accounts/summary - * Get account summary (portfolio overview) - */ -router.get('/accounts/summary', authHandler(investmentController.getAccountSummary)); - -/** - * POST /api/v1/investment/accounts - * Create a new investment account - * Body: { productId, initialDeposit } - */ -router.post('/accounts', authHandler(investmentController.createAccount)); - -/** - * GET /api/v1/investment/accounts/:accountId - * Get account details with performance - */ -router.get('/accounts/:accountId', authHandler(investmentController.getAccountById)); - -/** - * POST /api/v1/investment/accounts/:accountId/close - * Close an investment account - */ -router.post('/accounts/:accountId/close', authHandler(investmentController.closeAccount)); - -// ============================================================================ -// Transaction Routes (Authenticated) -// ============================================================================ - -/** - * GET /api/v1/investment/accounts/:accountId/transactions - * Get account transactions - * Query params: type, status, limit, offset - */ -router.get('/accounts/:accountId/transactions', authHandler(investmentController.getTransactions)); - -/** - * POST /api/v1/investment/accounts/:accountId/deposit - * Create a deposit - * Body: { amount } - */ -router.post('/accounts/:accountId/deposit', authHandler(investmentController.createDeposit)); - -/** - * POST /api/v1/investment/accounts/:accountId/withdraw - * Create a withdrawal request - * Body: { amount, bankInfo?, cryptoInfo? } - */ -router.post('/accounts/:accountId/withdraw', authHandler(investmentController.createWithdrawal)); - -/** - * GET /api/v1/investment/accounts/:accountId/distributions - * Get account distributions - */ -router.get('/accounts/:accountId/distributions', authHandler(investmentController.getDistributions)); - -// ============================================================================ -// Withdrawal Routes (Authenticated) -// ============================================================================ - -/** - * GET /api/v1/investment/withdrawals - * Get user's withdrawal requests - * Query params: status - */ -router.get('/withdrawals', authHandler(investmentController.getWithdrawals)); - -export { router as investmentRouter }; diff --git a/apps/backend/src/modules/investment/services/__tests__/account.service.spec.ts b/apps/backend/src/modules/investment/services/__tests__/account.service.spec.ts deleted file mode 100644 index 98f0aca..0000000 --- a/apps/backend/src/modules/investment/services/__tests__/account.service.spec.ts +++ /dev/null @@ -1,547 +0,0 @@ -/** - * Investment Account Service Unit Tests - * - * Tests for investment account service including: - * - Account creation and management - * - Balance tracking - * - Performance calculations - * - Account status management - */ - -import { mockDb, createMockQueryResult, resetDatabaseMocks } from '../../../../__tests__/mocks/database.mock'; - -// Mock database (account service uses in-memory storage) -jest.mock('../../../../shared/database', () => ({ - db: mockDb, -})); - -// Mock logger -jest.mock('../../../../shared/utils/logger', () => ({ - logger: { - info: jest.fn(), - error: jest.fn(), - warn: jest.fn(), - debug: jest.fn(), - }, -})); - -// Mock product service -const mockGetProductById = jest.fn(); -jest.mock('../product.service', () => ({ - productService: { - getProductById: mockGetProductById, - getAllProducts: jest.fn(), - }, -})); - -// Import service after mocks -import { accountService } from '../account.service'; - -describe('AccountService', () => { - beforeEach(() => { - resetDatabaseMocks(); - mockGetProductById.mockReset(); - jest.clearAllMocks(); - }); - - describe('createAccount', () => { - it('should create a new investment account', async () => { - const mockProduct = { - id: 'product-123', - code: 'atlas', - name: 'Atlas - El Guardián', - riskProfile: 'conservative', - minInvestment: 100, - isActive: true, - }; - - mockGetProductById.mockResolvedValueOnce(mockProduct); - - const result = await accountService.createAccount({ - userId: 'user-123', - productId: 'product-123', - initialDeposit: 1000, - }); - - expect(result.userId).toBe('user-123'); - expect(result.productId).toBe('product-123'); - expect(result.balance).toBe(1000); - expect(result.initialInvestment).toBe(1000); - expect(result.status).toBe('active'); - }); - - it('should validate minimum investment amount', async () => { - const mockProduct = { - id: 'product-123', - code: 'orion', - name: 'Orion - El Explorador', - riskProfile: 'moderate', - minInvestment: 500, - isActive: true, - }; - - mockGetProductById.mockResolvedValueOnce(mockProduct); - - await expect( - accountService.createAccount({ - userId: 'user-123', - productId: 'product-123', - initialDeposit: 100, - }) - ).rejects.toThrow('Minimum investment is 500'); - }); - - it('should reject inactive products', async () => { - const mockProduct = { - id: 'product-124', - code: 'inactive', - name: 'Inactive Product', - riskProfile: 'moderate', - minInvestment: 100, - isActive: false, - }; - - mockGetProductById.mockResolvedValueOnce(mockProduct); - - await expect( - accountService.createAccount({ - userId: 'user-123', - productId: 'product-124', - initialDeposit: 1000, - }) - ).rejects.toThrow('Product is not active'); - }); - - it('should prevent duplicate accounts for same product', async () => { - const mockProduct = { - id: 'product-123', - code: 'atlas', - name: 'Atlas', - riskProfile: 'conservative', - minInvestment: 100, - isActive: true, - }; - - mockGetProductById.mockResolvedValue(mockProduct); - - await accountService.createAccount({ - userId: 'user-123', - productId: 'product-123', - initialDeposit: 1000, - }); - - await expect( - accountService.createAccount({ - userId: 'user-123', - productId: 'product-123', - initialDeposit: 500, - }) - ).rejects.toThrow('Account already exists for this product'); - }); - - it('should handle non-existent product', async () => { - mockGetProductById.mockResolvedValueOnce(null); - - await expect( - accountService.createAccount({ - userId: 'user-123', - productId: 'non-existent', - initialDeposit: 1000, - }) - ).rejects.toThrow('Product not found'); - }); - }); - - describe('getUserAccounts', () => { - it('should retrieve all accounts for a user', async () => { - const mockProducts = [ - { id: 'product-1', code: 'atlas', name: 'Atlas' }, - { id: 'product-2', code: 'orion', name: 'Orion' }, - ]; - - mockGetProductById.mockImplementation((id) => - Promise.resolve(mockProducts.find(p => p.id === id)) - ); - - await accountService.createAccount({ - userId: 'user-123', - productId: 'product-1', - initialDeposit: 1000, - }); - - await accountService.createAccount({ - userId: 'user-123', - productId: 'product-2', - initialDeposit: 2000, - }); - - const result = await accountService.getUserAccounts('user-123'); - - expect(result).toHaveLength(2); - expect(result[0].userId).toBe('user-123'); - expect(result[1].userId).toBe('user-123'); - expect(result[0].product).toBeDefined(); - }); - - it('should return empty array for user with no accounts', async () => { - const result = await accountService.getUserAccounts('user-999'); - - expect(result).toEqual([]); - }); - - it('should include product information', async () => { - const mockProduct = { - id: 'product-123', - code: 'atlas', - name: 'Atlas - El Guardián', - riskProfile: 'conservative', - minInvestment: 100, - isActive: true, - }; - - mockGetProductById.mockResolvedValue(mockProduct); - - await accountService.createAccount({ - userId: 'user-123', - productId: 'product-123', - initialDeposit: 1000, - }); - - const result = await accountService.getUserAccounts('user-123'); - - expect(result[0].product).toEqual(mockProduct); - }); - }); - - describe('getAccountById', () => { - it('should retrieve an account by ID', async () => { - const mockProduct = { - id: 'product-123', - code: 'atlas', - name: 'Atlas', - minInvestment: 100, - isActive: true, - }; - - mockGetProductById.mockResolvedValue(mockProduct); - - const created = await accountService.createAccount({ - userId: 'user-123', - productId: 'product-123', - initialDeposit: 1000, - }); - - const result = await accountService.getAccountById(created.id); - - expect(result).toBeDefined(); - expect(result?.id).toBe(created.id); - expect(result?.product).toBeDefined(); - }); - - it('should return null for non-existent account', async () => { - const result = await accountService.getAccountById('non-existent'); - - expect(result).toBeNull(); - }); - }); - - describe('getAccountByUserAndProduct', () => { - it('should retrieve account by user and product', async () => { - const mockProduct = { - id: 'product-123', - code: 'atlas', - name: 'Atlas', - minInvestment: 100, - isActive: true, - }; - - mockGetProductById.mockResolvedValue(mockProduct); - - await accountService.createAccount({ - userId: 'user-123', - productId: 'product-123', - initialDeposit: 1000, - }); - - const result = await accountService.getAccountByUserAndProduct('user-123', 'product-123'); - - expect(result).toBeDefined(); - expect(result?.userId).toBe('user-123'); - expect(result?.productId).toBe('product-123'); - }); - - it('should return null if account does not exist', async () => { - const result = await accountService.getAccountByUserAndProduct('user-999', 'product-999'); - - expect(result).toBeNull(); - }); - - it('should exclude closed accounts', async () => { - const mockProduct = { - id: 'product-123', - code: 'atlas', - name: 'Atlas', - minInvestment: 100, - isActive: true, - }; - - mockGetProductById.mockResolvedValue(mockProduct); - - const account = await accountService.createAccount({ - userId: 'user-123', - productId: 'product-123', - initialDeposit: 1000, - }); - - await accountService.closeAccount(account.id); - - const result = await accountService.getAccountByUserAndProduct('user-123', 'product-123'); - - expect(result).toBeNull(); - }); - }); - - describe('updateAccountBalance', () => { - it('should update account balance', async () => { - const mockProduct = { - id: 'product-123', - code: 'atlas', - name: 'Atlas', - minInvestment: 100, - isActive: true, - }; - - mockGetProductById.mockResolvedValue(mockProduct); - - const account = await accountService.createAccount({ - userId: 'user-123', - productId: 'product-123', - initialDeposit: 1000, - }); - - const result = await accountService.updateAccountBalance(account.id, 1500); - - expect(result.balance).toBe(1500); - expect(result.updatedAt).toBeDefined(); - }); - - it('should calculate unrealized P&L', async () => { - const mockProduct = { - id: 'product-123', - code: 'atlas', - name: 'Atlas', - minInvestment: 100, - isActive: true, - }; - - mockGetProductById.mockResolvedValue(mockProduct); - - const account = await accountService.createAccount({ - userId: 'user-123', - productId: 'product-123', - initialDeposit: 1000, - }); - - const result = await accountService.updateAccountBalance(account.id, 1200); - - expect(result.unrealizedPnl).toBe(200); - expect(result.unrealizedPnlPercent).toBe(20); - }); - - it('should handle account not found', async () => { - await expect( - accountService.updateAccountBalance('non-existent', 1000) - ).rejects.toThrow('Account not found'); - }); - }); - - describe('closeAccount', () => { - it('should close an active account', async () => { - const mockProduct = { - id: 'product-123', - code: 'atlas', - name: 'Atlas', - minInvestment: 100, - isActive: true, - }; - - mockGetProductById.mockResolvedValue(mockProduct); - - const account = await accountService.createAccount({ - userId: 'user-123', - productId: 'product-123', - initialDeposit: 1000, - }); - - const result = await accountService.closeAccount(account.id); - - expect(result.status).toBe('closed'); - expect(result.closedAt).toBeDefined(); - }); - - it('should require zero balance to close', async () => { - const mockProduct = { - id: 'product-123', - code: 'atlas', - name: 'Atlas', - minInvestment: 100, - isActive: true, - }; - - mockGetProductById.mockResolvedValue(mockProduct); - - const account = await accountService.createAccount({ - userId: 'user-123', - productId: 'product-123', - initialDeposit: 1000, - }); - - await expect(accountService.closeAccount(account.id)).rejects.toThrow( - 'Cannot close account with non-zero balance' - ); - }); - - it('should prevent closing already closed account', async () => { - const mockProduct = { - id: 'product-123', - code: 'atlas', - name: 'Atlas', - minInvestment: 100, - isActive: true, - }; - - mockGetProductById.mockResolvedValue(mockProduct); - - const account = await accountService.createAccount({ - userId: 'user-123', - productId: 'product-123', - initialDeposit: 1000, - }); - - await accountService.updateAccountBalance(account.id, 0); - await accountService.closeAccount(account.id); - - await expect(accountService.closeAccount(account.id)).rejects.toThrow( - 'Account is already closed' - ); - }); - }); - - describe('suspendAccount', () => { - it('should suspend an active account', async () => { - const mockProduct = { - id: 'product-123', - code: 'atlas', - name: 'Atlas', - minInvestment: 100, - isActive: true, - }; - - mockGetProductById.mockResolvedValue(mockProduct); - - const account = await accountService.createAccount({ - userId: 'user-123', - productId: 'product-123', - initialDeposit: 1000, - }); - - const result = await accountService.suspendAccount(account.id); - - expect(result.status).toBe('suspended'); - }); - - it('should prevent operations on suspended account', async () => { - const mockProduct = { - id: 'product-123', - code: 'atlas', - name: 'Atlas', - minInvestment: 100, - isActive: true, - }; - - mockGetProductById.mockResolvedValue(mockProduct); - - const account = await accountService.createAccount({ - userId: 'user-123', - productId: 'product-123', - initialDeposit: 1000, - }); - - await accountService.suspendAccount(account.id); - - await expect( - accountService.updateAccountBalance(account.id, 1500) - ).rejects.toThrow('Account is suspended'); - }); - }); - - describe('getAccountSummary', () => { - it('should calculate account summary for user', async () => { - const mockProduct = { - id: 'product-123', - code: 'atlas', - name: 'Atlas', - minInvestment: 100, - isActive: true, - }; - - mockGetProductById.mockResolvedValue(mockProduct); - - await accountService.createAccount({ - userId: 'user-123', - productId: 'product-123', - initialDeposit: 1000, - }); - - const account2 = await accountService.createAccount({ - userId: 'user-123', - productId: 'product-124', - initialDeposit: 2000, - }); - - await accountService.updateAccountBalance(account2.id, 2500); - - const result = await accountService.getAccountSummary('user-123'); - - expect(result.totalBalance).toBeGreaterThan(0); - expect(result.totalDeposited).toBe(3000); - expect(result.totalEarnings).toBeGreaterThan(0); - expect(result.overallReturn).toBeGreaterThan(0); - expect(result.accounts).toHaveLength(2); - }); - - it('should handle user with no accounts', async () => { - const result = await accountService.getAccountSummary('user-999'); - - expect(result.totalBalance).toBe(0); - expect(result.totalDeposited).toBe(0); - expect(result.accounts).toEqual([]); - }); - - it('should exclude closed accounts from summary', async () => { - const mockProduct = { - id: 'product-123', - code: 'atlas', - name: 'Atlas', - minInvestment: 100, - isActive: true, - }; - - mockGetProductById.mockResolvedValue(mockProduct); - - const account = await accountService.createAccount({ - userId: 'user-123', - productId: 'product-123', - initialDeposit: 1000, - }); - - await accountService.updateAccountBalance(account.id, 0); - await accountService.closeAccount(account.id); - - const result = await accountService.getAccountSummary('user-123'); - - expect(result.accounts).toHaveLength(0); - expect(result.totalBalance).toBe(0); - }); - }); -}); diff --git a/apps/backend/src/modules/investment/services/__tests__/product.service.spec.ts b/apps/backend/src/modules/investment/services/__tests__/product.service.spec.ts deleted file mode 100644 index 4c08ae1..0000000 --- a/apps/backend/src/modules/investment/services/__tests__/product.service.spec.ts +++ /dev/null @@ -1,378 +0,0 @@ -/** - * Investment Product Service Unit Tests - * - * Tests for investment product service including: - * - Product retrieval and filtering - * - Product validation - * - Risk profile matching - */ - -import { mockDb, createMockQueryResult, resetDatabaseMocks } from '../../../../__tests__/mocks/database.mock'; - -// Mock database -jest.mock('../../../../shared/database', () => ({ - db: mockDb, -})); - -// Mock logger -jest.mock('../../../../shared/utils/logger', () => ({ - logger: { - info: jest.fn(), - error: jest.fn(), - warn: jest.fn(), - debug: jest.fn(), - }, -})); - -// Import service after mocks -import { productService } from '../product.service'; - -describe('ProductService', () => { - beforeEach(() => { - resetDatabaseMocks(); - jest.clearAllMocks(); - }); - - describe('getAllProducts', () => { - it('should retrieve all investment products', async () => { - const result = await productService.getAllProducts(); - - expect(result).toBeDefined(); - expect(result.length).toBeGreaterThan(0); - expect(result[0]).toHaveProperty('id'); - expect(result[0]).toHaveProperty('code'); - expect(result[0]).toHaveProperty('name'); - expect(result[0]).toHaveProperty('riskProfile'); - }); - - it('should return products with all required fields', async () => { - const result = await productService.getAllProducts(); - - result.forEach(product => { - expect(product).toHaveProperty('id'); - expect(product).toHaveProperty('code'); - expect(product).toHaveProperty('name'); - expect(product).toHaveProperty('description'); - expect(product).toHaveProperty('riskProfile'); - expect(product).toHaveProperty('targetReturnMin'); - expect(product).toHaveProperty('targetReturnMax'); - expect(product).toHaveProperty('minInvestment'); - expect(product).toHaveProperty('managementFee'); - expect(product).toHaveProperty('performanceFee'); - expect(product).toHaveProperty('isActive'); - }); - }); - - it('should filter active products only', async () => { - const result = await productService.getAllProducts({ activeOnly: true }); - - expect(result.every(p => p.isActive)).toBe(true); - }); - }); - - describe('getProductById', () => { - it('should retrieve a product by ID', async () => { - const allProducts = await productService.getAllProducts(); - const productId = allProducts[0].id; - - const result = await productService.getProductById(productId); - - expect(result).toBeDefined(); - expect(result?.id).toBe(productId); - }); - - it('should return null for non-existent product', async () => { - const result = await productService.getProductById('non-existent-id'); - - expect(result).toBeNull(); - }); - }); - - describe('getProductByCode', () => { - it('should retrieve Atlas product by code', async () => { - const result = await productService.getProductByCode('atlas'); - - expect(result).toBeDefined(); - expect(result?.code).toBe('atlas'); - expect(result?.name).toContain('Atlas'); - expect(result?.riskProfile).toBe('conservative'); - }); - - it('should retrieve Orion product by code', async () => { - const result = await productService.getProductByCode('orion'); - - expect(result).toBeDefined(); - expect(result?.code).toBe('orion'); - expect(result?.name).toContain('Orion'); - expect(result?.riskProfile).toBe('moderate'); - }); - - it('should retrieve Nova product by code', async () => { - const result = await productService.getProductByCode('nova'); - - expect(result).toBeDefined(); - expect(result?.code).toBe('nova'); - expect(result?.name).toContain('Nova'); - expect(result?.riskProfile).toBe('aggressive'); - }); - - it('should return null for invalid product code', async () => { - const result = await productService.getProductByCode('invalid-code'); - - expect(result).toBeNull(); - }); - - it('should be case-insensitive', async () => { - const result1 = await productService.getProductByCode('ATLAS'); - const result2 = await productService.getProductByCode('atlas'); - const result3 = await productService.getProductByCode('Atlas'); - - expect(result1?.code).toBe('atlas'); - expect(result2?.code).toBe('atlas'); - expect(result3?.code).toBe('atlas'); - }); - }); - - describe('getProductsByRiskProfile', () => { - it('should retrieve conservative products', async () => { - const result = await productService.getProductsByRiskProfile('conservative'); - - expect(result).toBeDefined(); - expect(result.length).toBeGreaterThan(0); - expect(result.every(p => p.riskProfile === 'conservative')).toBe(true); - }); - - it('should retrieve moderate products', async () => { - const result = await productService.getProductsByRiskProfile('moderate'); - - expect(result).toBeDefined(); - expect(result.length).toBeGreaterThan(0); - expect(result.every(p => p.riskProfile === 'moderate')).toBe(true); - }); - - it('should retrieve aggressive products', async () => { - const result = await productService.getProductsByRiskProfile('aggressive'); - - expect(result).toBeDefined(); - expect(result.length).toBeGreaterThan(0); - expect(result.every(p => p.riskProfile === 'aggressive')).toBe(true); - }); - - it('should return empty array for invalid risk profile', async () => { - const result = await productService.getProductsByRiskProfile('invalid' as any); - - expect(result).toEqual([]); - }); - }); - - describe('product characteristics', () => { - it('should have Atlas with lowest minimum investment', async () => { - const atlas = await productService.getProductByCode('atlas'); - - expect(atlas).toBeDefined(); - expect(atlas?.minInvestment).toBeLessThanOrEqual(100); - }); - - it('should have conservative products with lower drawdown', async () => { - const conservativeProducts = await productService.getProductsByRiskProfile('conservative'); - - conservativeProducts.forEach(product => { - expect(product.maxDrawdown).toBeLessThanOrEqual(10); - }); - }); - - it('should have aggressive products with higher return targets', async () => { - const aggressiveProducts = await productService.getProductsByRiskProfile('aggressive'); - - aggressiveProducts.forEach(product => { - expect(product.targetReturnMax).toBeGreaterThan(10); - }); - }); - - it('should have performance fee defined for all products', async () => { - const allProducts = await productService.getAllProducts(); - - allProducts.forEach(product => { - expect(product.performanceFee).toBeGreaterThanOrEqual(0); - expect(product.performanceFee).toBeLessThanOrEqual(100); - }); - }); - - it('should have valid target return ranges', async () => { - const allProducts = await productService.getAllProducts(); - - allProducts.forEach(product => { - expect(product.targetReturnMin).toBeGreaterThan(0); - expect(product.targetReturnMax).toBeGreaterThan(product.targetReturnMin); - }); - }); - }); - - describe('product features', () => { - it('should have Atlas with conservative features', async () => { - const atlas = await productService.getProductByCode('atlas'); - - expect(atlas?.features).toBeDefined(); - expect(atlas?.features.length).toBeGreaterThan(0); - expect(atlas?.strategy).toBeDefined(); - expect(atlas?.assets).toBeDefined(); - expect(atlas?.assets).toContain('BTC'); - expect(atlas?.assets).toContain('ETH'); - }); - - it('should have Orion with moderate features', async () => { - const orion = await productService.getProductByCode('orion'); - - expect(orion?.features).toBeDefined(); - expect(orion?.strategy).toBeDefined(); - expect(orion?.assets).toBeDefined(); - expect(orion?.assets.length).toBeGreaterThan(2); - }); - - it('should have Nova with aggressive features', async () => { - const nova = await productService.getProductByCode('nova'); - - expect(nova?.features).toBeDefined(); - expect(nova?.strategy).toBeDefined(); - expect(nova?.assets).toBeDefined(); - expect(nova?.tradingFrequency).toBeDefined(); - }); - - it('should have all products with descriptions', async () => { - const allProducts = await productService.getAllProducts(); - - allProducts.forEach(product => { - expect(product.description).toBeDefined(); - expect(product.description.length).toBeGreaterThan(50); - }); - }); - }); - - describe('getRecommendedProduct', () => { - it('should recommend conservative product for low risk tolerance', async () => { - const result = await productService.getRecommendedProduct({ - riskTolerance: 'low', - investmentAmount: 1000, - }); - - expect(result).toBeDefined(); - expect(result?.riskProfile).toBe('conservative'); - expect(result?.minInvestment).toBeLessThanOrEqual(1000); - }); - - it('should recommend moderate product for medium risk tolerance', async () => { - const result = await productService.getRecommendedProduct({ - riskTolerance: 'medium', - investmentAmount: 5000, - }); - - expect(result).toBeDefined(); - expect(result?.riskProfile).toBe('moderate'); - }); - - it('should recommend aggressive product for high risk tolerance', async () => { - const result = await productService.getRecommendedProduct({ - riskTolerance: 'high', - investmentAmount: 10000, - }); - - expect(result).toBeDefined(); - expect(result?.riskProfile).toBe('aggressive'); - }); - - it('should filter by minimum investment amount', async () => { - const result = await productService.getRecommendedProduct({ - riskTolerance: 'high', - investmentAmount: 200, - }); - - expect(result).toBeDefined(); - if (result) { - expect(result.minInvestment).toBeLessThanOrEqual(200); - } - }); - - it('should return null if no product matches criteria', async () => { - const result = await productService.getRecommendedProduct({ - riskTolerance: 'low', - investmentAmount: 10, - }); - - expect(result).toBeNull(); - }); - }); - - describe('validateProduct', () => { - it('should validate active product', async () => { - const allProducts = await productService.getAllProducts({ activeOnly: true }); - const product = allProducts[0]; - - const result = await productService.validateProduct(product.id); - - expect(result).toBe(true); - }); - - it('should reject inactive product', async () => { - const result = await productService.validateProduct('inactive-product-id'); - - expect(result).toBe(false); - }); - - it('should reject non-existent product', async () => { - const result = await productService.validateProduct('non-existent-id'); - - expect(result).toBe(false); - }); - }); - - describe('getProductPerformanceMetrics', () => { - it('should retrieve performance metrics for a product', async () => { - const atlas = await productService.getProductByCode('atlas'); - - const result = await productService.getProductPerformanceMetrics(atlas!.id); - - expect(result).toBeDefined(); - expect(result).toHaveProperty('targetReturnMin'); - expect(result).toHaveProperty('targetReturnMax'); - expect(result).toHaveProperty('maxDrawdown'); - expect(result).toHaveProperty('sharpeRatio'); - expect(result).toHaveProperty('volatility'); - }); - - it('should calculate risk-adjusted returns', async () => { - const orion = await productService.getProductByCode('orion'); - - const result = await productService.getProductPerformanceMetrics(orion!.id); - - expect(result.sharpeRatio).toBeGreaterThan(0); - expect(result.volatility).toBeGreaterThan(0); - }); - }); - - describe('compareProducts', () => { - it('should compare two products', async () => { - const atlas = await productService.getProductByCode('atlas'); - const nova = await productService.getProductByCode('nova'); - - const result = await productService.compareProducts(atlas!.id, nova!.id); - - expect(result).toBeDefined(); - expect(result.product1).toEqual(atlas); - expect(result.product2).toEqual(nova); - expect(result.comparison).toBeDefined(); - expect(result.comparison).toHaveProperty('riskDifference'); - expect(result.comparison).toHaveProperty('returnDifference'); - expect(result.comparison).toHaveProperty('feeDifference'); - }); - - it('should highlight key differences', async () => { - const atlas = await productService.getProductByCode('atlas'); - const orion = await productService.getProductByCode('orion'); - - const result = await productService.compareProducts(atlas!.id, orion!.id); - - expect(result.comparison.riskDifference).not.toBe(0); - expect(result.comparison.returnDifference).toBeGreaterThan(0); - }); - }); -}); diff --git a/apps/backend/src/modules/investment/services/__tests__/transaction.service.spec.ts b/apps/backend/src/modules/investment/services/__tests__/transaction.service.spec.ts deleted file mode 100644 index 471c803..0000000 --- a/apps/backend/src/modules/investment/services/__tests__/transaction.service.spec.ts +++ /dev/null @@ -1,606 +0,0 @@ -/** - * Investment Transaction Service Unit Tests - * - * Tests for transaction service including: - * - Deposits and withdrawals - * - Transaction tracking - * - Distribution processing - * - Fee calculations - */ - -import { mockDb, createMockQueryResult, resetDatabaseMocks } from '../../../../__tests__/mocks/database.mock'; - -// Mock database -jest.mock('../../../../shared/database', () => ({ - db: mockDb, -})); - -// Mock logger -jest.mock('../../../../shared/utils/logger', () => ({ - logger: { - info: jest.fn(), - error: jest.fn(), - warn: jest.fn(), - debug: jest.fn(), - }, -})); - -// Mock account service -const mockGetAccountById = jest.fn(); -const mockUpdateAccountBalance = jest.fn(); -jest.mock('../account.service', () => ({ - accountService: { - getAccountById: mockGetAccountById, - updateAccountBalance: mockUpdateAccountBalance, - }, -})); - -// Import service after mocks -import { transactionService } from '../transaction.service'; - -describe('TransactionService', () => { - beforeEach(() => { - resetDatabaseMocks(); - mockGetAccountById.mockReset(); - mockUpdateAccountBalance.mockReset(); - jest.clearAllMocks(); - }); - - describe('createDeposit', () => { - it('should create a new deposit transaction', async () => { - const mockAccount = { - id: 'account-123', - userId: 'user-123', - balance: 1000, - status: 'active', - }; - - mockGetAccountById.mockResolvedValueOnce(mockAccount); - mockUpdateAccountBalance.mockResolvedValueOnce({ - ...mockAccount, - balance: 1500, - }); - - const result = await transactionService.createDeposit({ - accountId: 'account-123', - amount: 500, - stripePaymentId: 'pi_123456', - }); - - expect(result.type).toBe('deposit'); - expect(result.amount).toBe(500); - expect(result.status).toBe('completed'); - expect(result.balanceBefore).toBe(1000); - expect(result.balanceAfter).toBe(1500); - expect(result.stripePaymentId).toBe('pi_123456'); - }); - - it('should validate minimum deposit amount', async () => { - await expect( - transactionService.createDeposit({ - accountId: 'account-123', - amount: 5, - }) - ).rejects.toThrow('Minimum deposit amount is 10'); - }); - - it('should reject deposit to suspended account', async () => { - const mockAccount = { - id: 'account-123', - userId: 'user-123', - balance: 1000, - status: 'suspended', - }; - - mockGetAccountById.mockResolvedValueOnce(mockAccount); - - await expect( - transactionService.createDeposit({ - accountId: 'account-123', - amount: 500, - }) - ).rejects.toThrow('Cannot deposit to suspended account'); - }); - - it('should reject deposit to closed account', async () => { - const mockAccount = { - id: 'account-123', - userId: 'user-123', - balance: 0, - status: 'closed', - }; - - mockGetAccountById.mockResolvedValueOnce(mockAccount); - - await expect( - transactionService.createDeposit({ - accountId: 'account-123', - amount: 500, - }) - ).rejects.toThrow('Cannot deposit to closed account'); - }); - - it('should handle account not found', async () => { - mockGetAccountById.mockResolvedValueOnce(null); - - await expect( - transactionService.createDeposit({ - accountId: 'non-existent', - amount: 500, - }) - ).rejects.toThrow('Account not found'); - }); - }); - - describe('createWithdrawalRequest', () => { - it('should create withdrawal request with bank info', async () => { - const mockAccount = { - id: 'account-123', - userId: 'user-123', - balance: 5000, - status: 'active', - }; - - mockGetAccountById.mockResolvedValueOnce(mockAccount); - - const result = await transactionService.createWithdrawalRequest({ - accountId: 'account-123', - amount: 1000, - bankInfo: { - bankName: 'Bank of Test', - accountNumber: '1234567890', - routingNumber: '987654321', - accountHolderName: 'John Doe', - }, - }); - - expect(result.amount).toBe(1000); - expect(result.status).toBe('pending'); - expect(result.bankInfo).toBeDefined(); - expect(result.bankInfo?.bankName).toBe('Bank of Test'); - expect(result.cryptoInfo).toBeNull(); - }); - - it('should create withdrawal request with crypto info', async () => { - const mockAccount = { - id: 'account-123', - userId: 'user-123', - balance: 5000, - status: 'active', - }; - - mockGetAccountById.mockResolvedValueOnce(mockAccount); - - const result = await transactionService.createWithdrawalRequest({ - accountId: 'account-123', - amount: 2000, - cryptoInfo: { - network: 'ethereum', - address: '0x1234567890abcdef', - }, - }); - - expect(result.cryptoInfo).toBeDefined(); - expect(result.cryptoInfo?.network).toBe('ethereum'); - expect(result.cryptoInfo?.address).toBe('0x1234567890abcdef'); - expect(result.bankInfo).toBeNull(); - }); - - it('should validate minimum withdrawal amount', async () => { - await expect( - transactionService.createWithdrawalRequest({ - accountId: 'account-123', - amount: 5, - }) - ).rejects.toThrow('Minimum withdrawal amount is 50'); - }); - - it('should validate sufficient balance', async () => { - const mockAccount = { - id: 'account-123', - userId: 'user-123', - balance: 100, - status: 'active', - }; - - mockGetAccountById.mockResolvedValueOnce(mockAccount); - - await expect( - transactionService.createWithdrawalRequest({ - accountId: 'account-123', - amount: 500, - }) - ).rejects.toThrow('Insufficient balance'); - }); - - it('should require either bank or crypto info', async () => { - const mockAccount = { - id: 'account-123', - userId: 'user-123', - balance: 5000, - status: 'active', - }; - - mockGetAccountById.mockResolvedValueOnce(mockAccount); - - await expect( - transactionService.createWithdrawalRequest({ - accountId: 'account-123', - amount: 1000, - }) - ).rejects.toThrow('Either bank info or crypto info is required'); - }); - }); - - describe('processWithdrawal', () => { - it('should process approved withdrawal', async () => { - const mockAccount = { - id: 'account-123', - userId: 'user-123', - balance: 5000, - status: 'active', - }; - - const mockWithdrawal = { - id: 'withdrawal-123', - accountId: 'account-123', - amount: 1000, - status: 'pending', - }; - - mockGetAccountById.mockResolvedValueOnce(mockAccount); - mockUpdateAccountBalance.mockResolvedValueOnce({ - ...mockAccount, - balance: 4000, - }); - - const result = await transactionService.processWithdrawal( - 'withdrawal-123', - 'approved' - ); - - expect(result.status).toBe('completed'); - expect(result.completedAt).toBeDefined(); - expect(mockUpdateAccountBalance).toHaveBeenCalledWith('account-123', 4000); - }); - - it('should reject withdrawal with reason', async () => { - const mockWithdrawal = { - id: 'withdrawal-123', - accountId: 'account-123', - amount: 1000, - status: 'pending', - }; - - const result = await transactionService.processWithdrawal( - 'withdrawal-123', - 'rejected', - 'Suspicious activity detected' - ); - - expect(result.status).toBe('rejected'); - expect(result.rejectionReason).toBe('Suspicious activity detected'); - expect(mockUpdateAccountBalance).not.toHaveBeenCalled(); - }); - - it('should handle withdrawal not found', async () => { - await expect( - transactionService.processWithdrawal('non-existent', 'approved') - ).rejects.toThrow('Withdrawal request not found'); - }); - - it('should prevent processing already completed withdrawal', async () => { - const mockWithdrawal = { - id: 'withdrawal-123', - accountId: 'account-123', - amount: 1000, - status: 'completed', - }; - - await expect( - transactionService.processWithdrawal('withdrawal-123', 'approved') - ).rejects.toThrow('Withdrawal already processed'); - }); - }); - - describe('getAccountTransactions', () => { - it('should retrieve all transactions for an account', async () => { - const mockTransactions = [ - { - id: 'tx-1', - accountId: 'account-123', - type: 'deposit', - amount: 1000, - status: 'completed', - createdAt: new Date(), - }, - { - id: 'tx-2', - accountId: 'account-123', - type: 'withdrawal', - amount: 500, - status: 'completed', - createdAt: new Date(), - }, - ]; - - const result = await transactionService.getAccountTransactions('account-123'); - - expect(result).toBeDefined(); - expect(Array.isArray(result)).toBe(true); - }); - - it('should filter transactions by type', async () => { - const result = await transactionService.getAccountTransactions('account-123', { - type: 'deposit', - }); - - expect(result.every(tx => tx.type === 'deposit')).toBe(true); - }); - - it('should filter transactions by status', async () => { - const result = await transactionService.getAccountTransactions('account-123', { - status: 'completed', - }); - - expect(result.every(tx => tx.status === 'completed')).toBe(true); - }); - - it('should filter transactions by date range', async () => { - const startDate = new Date('2024-01-01'); - const endDate = new Date('2024-12-31'); - - const result = await transactionService.getAccountTransactions('account-123', { - startDate, - endDate, - }); - - result.forEach(tx => { - expect(tx.createdAt >= startDate).toBe(true); - expect(tx.createdAt <= endDate).toBe(true); - }); - }); - - it('should limit results', async () => { - const result = await transactionService.getAccountTransactions('account-123', { - limit: 10, - }); - - expect(result.length).toBeLessThanOrEqual(10); - }); - }); - - describe('createDistribution', () => { - it('should create earnings distribution', async () => { - const mockAccount = { - id: 'account-123', - userId: 'user-123', - balance: 10000, - product: { - performanceFee: 20, - }, - }; - - mockGetAccountById.mockResolvedValueOnce(mockAccount); - - const result = await transactionService.createDistribution({ - accountId: 'account-123', - periodStart: new Date('2024-01-01'), - periodEnd: new Date('2024-01-31'), - grossEarnings: 1000, - }); - - expect(result.grossEarnings).toBe(1000); - expect(result.performanceFee).toBe(200); - expect(result.netEarnings).toBe(800); - expect(result.status).toBe('pending'); - }); - - it('should calculate performance fee correctly', async () => { - const mockAccount = { - id: 'account-123', - userId: 'user-123', - balance: 10000, - product: { - performanceFee: 25, - }, - }; - - mockGetAccountById.mockResolvedValueOnce(mockAccount); - - const result = await transactionService.createDistribution({ - accountId: 'account-123', - periodStart: new Date('2024-01-01'), - periodEnd: new Date('2024-01-31'), - grossEarnings: 2000, - }); - - expect(result.performanceFee).toBe(500); - expect(result.netEarnings).toBe(1500); - }); - - it('should handle zero or negative earnings', async () => { - const mockAccount = { - id: 'account-123', - userId: 'user-123', - balance: 10000, - product: { - performanceFee: 20, - }, - }; - - mockGetAccountById.mockResolvedValueOnce(mockAccount); - - const result = await transactionService.createDistribution({ - accountId: 'account-123', - periodStart: new Date('2024-01-01'), - periodEnd: new Date('2024-01-31'), - grossEarnings: -500, - }); - - expect(result.grossEarnings).toBe(-500); - expect(result.performanceFee).toBe(0); - expect(result.netEarnings).toBe(-500); - }); - }); - - describe('processDistribution', () => { - it('should process pending distribution', async () => { - const mockAccount = { - id: 'account-123', - userId: 'user-123', - balance: 10000, - status: 'active', - }; - - const mockDistribution = { - id: 'dist-123', - accountId: 'account-123', - netEarnings: 800, - status: 'pending', - }; - - mockGetAccountById.mockResolvedValueOnce(mockAccount); - mockUpdateAccountBalance.mockResolvedValueOnce({ - ...mockAccount, - balance: 10800, - }); - - const result = await transactionService.processDistribution('dist-123'); - - expect(result.status).toBe('distributed'); - expect(result.distributedAt).toBeDefined(); - expect(mockUpdateAccountBalance).toHaveBeenCalledWith('account-123', 10800); - }); - - it('should create transaction record for distribution', async () => { - const mockAccount = { - id: 'account-123', - userId: 'user-123', - balance: 10000, - status: 'active', - }; - - mockGetAccountById.mockResolvedValueOnce(mockAccount); - mockUpdateAccountBalance.mockResolvedValueOnce({ - ...mockAccount, - balance: 10800, - }); - - await transactionService.processDistribution('dist-123'); - - // Verify transaction was created - const transactions = await transactionService.getAccountTransactions('account-123'); - const distributionTx = transactions.find(tx => tx.type === 'distribution'); - - expect(distributionTx).toBeDefined(); - }); - - it('should handle negative distribution (loss)', async () => { - const mockAccount = { - id: 'account-123', - userId: 'user-123', - balance: 10000, - status: 'active', - }; - - mockGetAccountById.mockResolvedValueOnce(mockAccount); - mockUpdateAccountBalance.mockResolvedValueOnce({ - ...mockAccount, - balance: 9500, - }); - - const result = await transactionService.processDistribution('dist-124'); - - expect(result.status).toBe('distributed'); - expect(mockUpdateAccountBalance).toHaveBeenCalled(); - }); - }); - - describe('getTransactionById', () => { - it('should retrieve a transaction by ID', async () => { - const mockAccount = { - id: 'account-123', - userId: 'user-123', - balance: 1000, - status: 'active', - }; - - mockGetAccountById.mockResolvedValueOnce(mockAccount); - mockUpdateAccountBalance.mockResolvedValueOnce({ - ...mockAccount, - balance: 1500, - }); - - const created = await transactionService.createDeposit({ - accountId: 'account-123', - amount: 500, - }); - - const result = await transactionService.getTransactionById(created.id); - - expect(result).toBeDefined(); - expect(result?.id).toBe(created.id); - }); - - it('should return null for non-existent transaction', async () => { - const result = await transactionService.getTransactionById('non-existent'); - - expect(result).toBeNull(); - }); - }); - - describe('getUserTransactions', () => { - it('should retrieve all transactions for a user', async () => { - const result = await transactionService.getUserTransactions('user-123'); - - expect(result).toBeDefined(); - expect(Array.isArray(result)).toBe(true); - }); - - it('should filter by account', async () => { - const result = await transactionService.getUserTransactions('user-123', { - accountId: 'account-123', - }); - - expect(result.every(tx => tx.accountId === 'account-123')).toBe(true); - }); - - it('should paginate results', async () => { - const page1 = await transactionService.getUserTransactions('user-123', { - limit: 10, - offset: 0, - }); - - const page2 = await transactionService.getUserTransactions('user-123', { - limit: 10, - offset: 10, - }); - - expect(page1.length).toBeLessThanOrEqual(10); - expect(page2.length).toBeLessThanOrEqual(10); - }); - }); - - describe('getTransactionStatistics', () => { - it('should calculate transaction statistics', async () => { - const result = await transactionService.getTransactionStatistics('account-123'); - - expect(result).toBeDefined(); - expect(result).toHaveProperty('totalDeposits'); - expect(result).toHaveProperty('totalWithdrawals'); - expect(result).toHaveProperty('totalEarnings'); - expect(result).toHaveProperty('totalFees'); - expect(result).toHaveProperty('netFlow'); - }); - - it('should filter statistics by date range', async () => { - const result = await transactionService.getTransactionStatistics('account-123', { - startDate: new Date('2024-01-01'), - endDate: new Date('2024-12-31'), - }); - - expect(result).toBeDefined(); - }); - }); -}); diff --git a/apps/backend/src/modules/investment/services/account.service.ts b/apps/backend/src/modules/investment/services/account.service.ts deleted file mode 100644 index 6f25d5e..0000000 --- a/apps/backend/src/modules/investment/services/account.service.ts +++ /dev/null @@ -1,344 +0,0 @@ -/** - * Investment Account Service - * Manages user investment accounts - */ - -import { v4 as uuidv4 } from 'uuid'; -import { productService, InvestmentProduct } from './product.service'; - -// ============================================================================ -// Types -// ============================================================================ - -export type AccountStatus = 'active' | 'suspended' | 'closed'; - -export interface InvestmentAccount { - id: string; - userId: string; - productId: string; - product?: InvestmentProduct; - status: AccountStatus; - balance: number; - initialInvestment: number; - totalDeposited: number; - totalWithdrawn: number; - totalEarnings: number; - totalFeesPaid: number; - unrealizedPnl: number; - unrealizedPnlPercent: number; - openedAt: Date; - closedAt: Date | null; - updatedAt: Date; -} - -export interface CreateAccountInput { - userId: string; - productId: string; - initialDeposit: number; -} - -export interface AccountSummary { - totalBalance: number; - totalEarnings: number; - totalDeposited: number; - totalWithdrawn: number; - overallReturn: number; - overallReturnPercent: number; - accounts: InvestmentAccount[]; -} - -// ============================================================================ -// In-Memory Storage -// ============================================================================ - -const accounts: Map = new Map(); - -// ============================================================================ -// Account Service -// ============================================================================ - -class AccountService { - /** - * Get all accounts for a user - */ - async getUserAccounts(userId: string): Promise { - const userAccounts = Array.from(accounts.values()).filter((a) => a.userId === userId); - - // Attach product info - for (const account of userAccounts) { - account.product = (await productService.getProductById(account.productId)) || undefined; - } - - return userAccounts; - } - - /** - * Get account by ID - */ - async getAccountById(accountId: string): Promise { - const account = accounts.get(accountId); - if (!account) return null; - - account.product = (await productService.getProductById(account.productId)) || undefined; - return account; - } - - /** - * Get account by user and product - */ - async getAccountByUserAndProduct( - userId: string, - productId: string - ): Promise { - const account = Array.from(accounts.values()).find( - (a) => a.userId === userId && a.productId === productId && a.status !== 'closed' - ); - - if (!account) return null; - - account.product = (await productService.getProductById(account.productId)) || undefined; - return account; - } - - /** - * Create a new investment account - */ - async createAccount(input: CreateAccountInput): Promise { - // Validate product exists - const product = await productService.getProductById(input.productId); - if (!product) { - throw new Error(`Product not found: ${input.productId}`); - } - - // Check minimum investment - if (input.initialDeposit < product.minInvestment) { - throw new Error( - `Minimum investment for ${product.name} is $${product.minInvestment}` - ); - } - - // Check if user already has an account with this product - const existingAccount = await this.getAccountByUserAndProduct( - input.userId, - input.productId - ); - if (existingAccount) { - throw new Error(`User already has an account with ${product.name}`); - } - - const account: InvestmentAccount = { - id: uuidv4(), - userId: input.userId, - productId: input.productId, - product, - status: 'active', - balance: input.initialDeposit, - initialInvestment: input.initialDeposit, - totalDeposited: input.initialDeposit, - totalWithdrawn: 0, - totalEarnings: 0, - totalFeesPaid: 0, - unrealizedPnl: 0, - unrealizedPnlPercent: 0, - openedAt: new Date(), - closedAt: null, - updatedAt: new Date(), - }; - - accounts.set(account.id, account); - return account; - } - - /** - * Deposit funds to an account - */ - async deposit(accountId: string, amount: number): Promise { - const account = accounts.get(accountId); - if (!account) { - throw new Error(`Account not found: ${accountId}`); - } - - if (account.status !== 'active') { - throw new Error(`Cannot deposit to ${account.status} account`); - } - - if (amount <= 0) { - throw new Error('Deposit amount must be positive'); - } - - account.balance += amount; - account.totalDeposited += amount; - account.updatedAt = new Date(); - - return account; - } - - /** - * Record earnings for an account - */ - async recordEarnings( - accountId: string, - grossEarnings: number, - performanceFee: number - ): Promise { - const account = accounts.get(accountId); - if (!account) { - throw new Error(`Account not found: ${accountId}`); - } - - const netEarnings = grossEarnings - performanceFee; - - account.balance += netEarnings; - account.totalEarnings += netEarnings; - account.totalFeesPaid += performanceFee; - account.updatedAt = new Date(); - - return account; - } - - /** - * Update unrealized P&L - */ - async updateUnrealizedPnl( - accountId: string, - unrealizedPnl: number - ): Promise { - const account = accounts.get(accountId); - if (!account) { - throw new Error(`Account not found: ${accountId}`); - } - - account.unrealizedPnl = unrealizedPnl; - account.unrealizedPnlPercent = - account.totalDeposited > 0 - ? (unrealizedPnl / account.totalDeposited) * 100 - : 0; - account.updatedAt = new Date(); - - return account; - } - - /** - * Close an account - */ - async closeAccount(accountId: string): Promise { - const account = accounts.get(accountId); - if (!account) { - throw new Error(`Account not found: ${accountId}`); - } - - if (account.status === 'closed') { - throw new Error('Account is already closed'); - } - - account.status = 'closed'; - account.closedAt = new Date(); - account.updatedAt = new Date(); - - return account; - } - - /** - * Suspend an account - */ - async suspendAccount(accountId: string): Promise { - const account = accounts.get(accountId); - if (!account) { - throw new Error(`Account not found: ${accountId}`); - } - - account.status = 'suspended'; - account.updatedAt = new Date(); - - return account; - } - - /** - * Reactivate a suspended account - */ - async reactivateAccount(accountId: string): Promise { - const account = accounts.get(accountId); - if (!account) { - throw new Error(`Account not found: ${accountId}`); - } - - if (account.status !== 'suspended') { - throw new Error('Only suspended accounts can be reactivated'); - } - - account.status = 'active'; - account.updatedAt = new Date(); - - return account; - } - - /** - * Get account summary for a user - */ - async getAccountSummary(userId: string): Promise { - const userAccounts = await this.getUserAccounts(userId); - - const summary: AccountSummary = { - totalBalance: 0, - totalEarnings: 0, - totalDeposited: 0, - totalWithdrawn: 0, - overallReturn: 0, - overallReturnPercent: 0, - accounts: userAccounts, - }; - - for (const account of userAccounts) { - if (account.status !== 'closed') { - summary.totalBalance += account.balance; - summary.totalEarnings += account.totalEarnings; - summary.totalDeposited += account.totalDeposited; - summary.totalWithdrawn += account.totalWithdrawn; - } - } - - summary.overallReturn = summary.totalBalance - summary.totalDeposited + summary.totalWithdrawn; - summary.overallReturnPercent = - summary.totalDeposited > 0 - ? (summary.overallReturn / summary.totalDeposited) * 100 - : 0; - - return summary; - } - - /** - * Get account performance history - */ - async getAccountPerformance( - accountId: string, - days: number = 30 - ): Promise<{ date: string; balance: number; pnl: number }[]> { - const account = accounts.get(accountId); - if (!account) { - throw new Error(`Account not found: ${accountId}`); - } - - // Generate mock performance data - const performance: { date: string; balance: number; pnl: number }[] = []; - let balance = account.initialInvestment; - - for (let i = days; i >= 0; i--) { - const date = new Date(); - date.setDate(date.getDate() - i); - - const dailyChange = balance * ((Math.random() - 0.3) * 0.02); - balance += dailyChange; - - performance.push({ - date: date.toISOString().split('T')[0], - balance, - pnl: balance - account.initialInvestment, - }); - } - - return performance; - } -} - -// Export singleton instance -export const accountService = new AccountService(); diff --git a/apps/backend/src/modules/investment/services/product.service.ts b/apps/backend/src/modules/investment/services/product.service.ts deleted file mode 100644 index 60da908..0000000 --- a/apps/backend/src/modules/investment/services/product.service.ts +++ /dev/null @@ -1,247 +0,0 @@ -/** - * Investment Product Service - * Manages investment products (Atlas, Orion, Nova) - */ - -import { v4 as uuidv4 } from 'uuid'; - -// ============================================================================ -// Types -// ============================================================================ - -export type RiskProfile = 'conservative' | 'moderate' | 'aggressive'; - -export interface InvestmentProduct { - id: string; - code: string; - name: string; - description: string; - riskProfile: RiskProfile; - targetReturnMin: number; - targetReturnMax: number; - maxDrawdown: number; - minInvestment: number; - managementFee: number; - performanceFee: number; - isActive: boolean; - features: string[]; - strategy: string; - assets: string[]; - tradingFrequency: string; - createdAt: Date; -} - -// ============================================================================ -// Default Products -// ============================================================================ - -const DEFAULT_PRODUCTS: InvestmentProduct[] = [ - { - id: uuidv4(), - code: 'atlas', - name: 'Atlas - El Guardián', - description: - 'Estrategia conservadora diseñada para inversores que priorizan la preservación del capital. Utiliza mean reversion y grid trading en los principales activos del mercado.', - riskProfile: 'conservative', - targetReturnMin: 3, - targetReturnMax: 5, - maxDrawdown: 5, - minInvestment: 100, - managementFee: 0, - performanceFee: 20, - isActive: true, - features: [ - 'Bajo riesgo', - 'Preservación de capital', - 'Operaciones conservadoras', - 'Solo activos principales', - ], - strategy: 'Mean reversion + Grid trading', - assets: ['BTC', 'ETH'], - tradingFrequency: '2-5 trades/día', - createdAt: new Date(), - }, - { - id: uuidv4(), - code: 'orion', - name: 'Orion - El Explorador', - description: - 'Estrategia moderada para inversores que buscan un balance entre riesgo y rentabilidad. Aprovecha tendencias del mercado y breakouts en un rango más amplio de activos.', - riskProfile: 'moderate', - targetReturnMin: 5, - targetReturnMax: 10, - maxDrawdown: 10, - minInvestment: 500, - managementFee: 0, - performanceFee: 20, - isActive: true, - features: [ - 'Riesgo moderado', - 'Balance rentabilidad/riesgo', - 'Diversificación activos', - 'Seguimiento de tendencias', - ], - strategy: 'Trend following + Breakouts', - assets: ['BTC', 'ETH', 'Top 10 Altcoins'], - tradingFrequency: '5-15 trades/día', - createdAt: new Date(), - }, - { - id: uuidv4(), - code: 'nova', - name: 'Nova - La Estrella', - description: - 'Estrategia agresiva para inversores con alta tolerancia al riesgo. Maximiza oportunidades mediante momentum trading y scalping en todos los pares disponibles.', - riskProfile: 'aggressive', - targetReturnMin: 10, - targetReturnMax: 20, - maxDrawdown: 20, - minInvestment: 1000, - managementFee: 0, - performanceFee: 20, - isActive: true, - features: [ - 'Alto riesgo/alta rentabilidad', - 'Trading activo', - 'Todos los activos', - 'Máximas oportunidades', - ], - strategy: 'Momentum + Scalping', - assets: ['Todos los pares disponibles'], - tradingFrequency: '20+ trades/día', - createdAt: new Date(), - }, -]; - -// ============================================================================ -// In-Memory Storage -// ============================================================================ - -const products: Map = new Map( - DEFAULT_PRODUCTS.map((p) => [p.id, p]) -); - -// ============================================================================ -// Product Service -// ============================================================================ - -class ProductService { - /** - * Get all active products - */ - async getProducts(): Promise { - return Array.from(products.values()).filter((p) => p.isActive); - } - - /** - * Get product by ID - */ - async getProductById(id: string): Promise { - return products.get(id) || null; - } - - /** - * Get product by code - */ - async getProductByCode(code: string): Promise { - return Array.from(products.values()).find((p) => p.code === code) || null; - } - - /** - * Get products by risk profile - */ - async getProductsByRiskProfile(riskProfile: RiskProfile): Promise { - return Array.from(products.values()).filter( - (p) => p.riskProfile === riskProfile && p.isActive - ); - } - - /** - * Create a new product (admin only) - */ - async createProduct( - input: Omit - ): Promise { - const product: InvestmentProduct = { - ...input, - id: uuidv4(), - createdAt: new Date(), - }; - products.set(product.id, product); - return product; - } - - /** - * Update a product (admin only) - */ - async updateProduct( - id: string, - updates: Partial> - ): Promise { - const product = products.get(id); - if (!product) return null; - - const updated = { ...product, ...updates }; - products.set(id, updated); - return updated; - } - - /** - * Deactivate a product (admin only) - */ - async deactivateProduct(id: string): Promise { - const product = products.get(id); - if (!product) return false; - - product.isActive = false; - return true; - } - - /** - * Get product statistics - */ - async getProductStats(_productId: string): Promise<{ - totalInvestors: number; - totalAum: number; - avgReturn: number; - winRate: number; - }> { - // TODO: Calculate from real data - return { - totalInvestors: Math.floor(Math.random() * 1000) + 100, - totalAum: Math.floor(Math.random() * 10000000) + 1000000, - avgReturn: Math.random() * 15 + 5, - winRate: Math.random() * 20 + 60, - }; - } - - /** - * Get product performance history - */ - async getProductPerformance( - productId: string, - period: 'week' | 'month' | '3months' | 'year' - ): Promise<{ date: string; return: number }[]> { - const days = - period === 'week' ? 7 : period === 'month' ? 30 : period === '3months' ? 90 : 365; - - const performance: { date: string; return: number }[] = []; - let cumulativeReturn = 0; - - for (let i = days; i >= 0; i--) { - const date = new Date(); - date.setDate(date.getDate() - i); - const dailyReturn = (Math.random() - 0.3) * 2; - cumulativeReturn += dailyReturn; - performance.push({ - date: date.toISOString().split('T')[0], - return: cumulativeReturn, - }); - } - - return performance; - } -} - -// Export singleton instance -export const productService = new ProductService(); diff --git a/apps/backend/src/modules/investment/services/transaction.service.ts b/apps/backend/src/modules/investment/services/transaction.service.ts deleted file mode 100644 index 271b8cc..0000000 --- a/apps/backend/src/modules/investment/services/transaction.service.ts +++ /dev/null @@ -1,589 +0,0 @@ -/** - * Investment Transaction Service - * Manages deposits, withdrawals, and distributions - */ - -import { v4 as uuidv4 } from 'uuid'; -import { accountService } from './account.service'; - -// ============================================================================ -// Types -// ============================================================================ - -export type TransactionType = 'deposit' | 'withdrawal' | 'earning' | 'fee' | 'distribution'; -export type TransactionStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled'; -export type WithdrawalStatus = 'pending' | 'processing' | 'completed' | 'rejected'; - -export interface Transaction { - id: string; - accountId: string; - userId: string; - type: TransactionType; - status: TransactionStatus; - amount: number; - balanceBefore: number; - balanceAfter: number; - stripePaymentId: string | null; - description: string; - processedAt: Date | null; - createdAt: Date; -} - -export interface WithdrawalRequest { - id: string; - accountId: string; - userId: string; - amount: number; - status: WithdrawalStatus; - bankInfo: { - bankName: string; - accountNumber: string; - routingNumber: string; - accountHolderName: string; - } | null; - cryptoInfo: { - network: string; - address: string; - } | null; - rejectionReason: string | null; - requestedAt: Date; - processedAt: Date | null; - completedAt: Date | null; -} - -export interface Distribution { - id: string; - accountId: string; - userId: string; - periodStart: Date; - periodEnd: Date; - grossEarnings: number; - performanceFee: number; - netEarnings: number; - status: 'pending' | 'distributed'; - distributedAt: Date | null; - createdAt: Date; -} - -export interface CreateDepositInput { - accountId: string; - amount: number; - stripePaymentId?: string; -} - -export interface CreateWithdrawalInput { - accountId: string; - amount: number; - bankInfo?: { - bankName: string; - accountNumber: string; - routingNumber: string; - accountHolderName: string; - }; - cryptoInfo?: { - network: string; - address: string; - }; -} - -// ============================================================================ -// In-Memory Storage -// ============================================================================ - -const transactions: Map = new Map(); -const withdrawalRequests: Map = new Map(); -const distributions: Map = new Map(); - -// ============================================================================ -// Transaction Service -// ============================================================================ - -class TransactionService { - // ========================================================================== - // Transactions - // ========================================================================== - - /** - * Get transactions for an account - */ - async getAccountTransactions( - accountId: string, - options: { - type?: TransactionType; - status?: TransactionStatus; - limit?: number; - offset?: number; - } = {} - ): Promise<{ transactions: Transaction[]; total: number }> { - let accountTransactions = Array.from(transactions.values()) - .filter((t) => t.accountId === accountId) - .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); - - if (options.type) { - accountTransactions = accountTransactions.filter((t) => t.type === options.type); - } - - if (options.status) { - accountTransactions = accountTransactions.filter((t) => t.status === options.status); - } - - const total = accountTransactions.length; - - if (options.offset) { - accountTransactions = accountTransactions.slice(options.offset); - } - - if (options.limit) { - accountTransactions = accountTransactions.slice(0, options.limit); - } - - return { transactions: accountTransactions, total }; - } - - /** - * Get transaction by ID - */ - async getTransactionById(transactionId: string): Promise { - return transactions.get(transactionId) || null; - } - - /** - * Create a deposit transaction - */ - async createDeposit(input: CreateDepositInput): Promise { - const account = await accountService.getAccountById(input.accountId); - if (!account) { - throw new Error(`Account not found: ${input.accountId}`); - } - - if (input.amount <= 0) { - throw new Error('Deposit amount must be positive'); - } - - const balanceBefore = account.balance; - - // Process deposit - await accountService.deposit(input.accountId, input.amount); - - const transaction: Transaction = { - id: uuidv4(), - accountId: input.accountId, - userId: account.userId, - type: 'deposit', - status: 'completed', - amount: input.amount, - balanceBefore, - balanceAfter: balanceBefore + input.amount, - stripePaymentId: input.stripePaymentId || null, - description: `Deposit of $${input.amount.toFixed(2)}`, - processedAt: new Date(), - createdAt: new Date(), - }; - - transactions.set(transaction.id, transaction); - return transaction; - } - - /** - * Create a pending deposit (for Stripe webhooks) - */ - async createPendingDeposit( - accountId: string, - amount: number, - stripePaymentId: string - ): Promise { - const account = await accountService.getAccountById(accountId); - if (!account) { - throw new Error(`Account not found: ${accountId}`); - } - - const transaction: Transaction = { - id: uuidv4(), - accountId, - userId: account.userId, - type: 'deposit', - status: 'pending', - amount, - balanceBefore: account.balance, - balanceAfter: account.balance, - stripePaymentId, - description: `Pending deposit of $${amount.toFixed(2)}`, - processedAt: null, - createdAt: new Date(), - }; - - transactions.set(transaction.id, transaction); - return transaction; - } - - /** - * Complete a pending deposit - */ - async completeDeposit(transactionId: string): Promise { - const transaction = transactions.get(transactionId); - if (!transaction) { - throw new Error(`Transaction not found: ${transactionId}`); - } - - if (transaction.status !== 'pending') { - throw new Error('Transaction is not pending'); - } - - // Process deposit - await accountService.deposit(transaction.accountId, transaction.amount); - - // Get updated account - const account = await accountService.getAccountById(transaction.accountId); - - transaction.status = 'completed'; - transaction.balanceAfter = account!.balance; - transaction.processedAt = new Date(); - transaction.description = `Deposit of $${transaction.amount.toFixed(2)}`; - - return transaction; - } - - // ========================================================================== - // Withdrawals - // ========================================================================== - - /** - * Get withdrawal requests for a user - */ - async getUserWithdrawals( - userId: string, - status?: WithdrawalStatus - ): Promise { - let userWithdrawals = Array.from(withdrawalRequests.values()) - .filter((w) => w.userId === userId) - .sort((a, b) => b.requestedAt.getTime() - a.requestedAt.getTime()); - - if (status) { - userWithdrawals = userWithdrawals.filter((w) => w.status === status); - } - - return userWithdrawals; - } - - /** - * Get withdrawal request by ID - */ - async getWithdrawalById(withdrawalId: string): Promise { - return withdrawalRequests.get(withdrawalId) || null; - } - - /** - * Create a withdrawal request - */ - async createWithdrawal( - userId: string, - input: CreateWithdrawalInput - ): Promise { - const account = await accountService.getAccountById(input.accountId); - if (!account) { - throw new Error(`Account not found: ${input.accountId}`); - } - - if (account.userId !== userId) { - throw new Error('Unauthorized'); - } - - if (account.status !== 'active') { - throw new Error('Cannot withdraw from inactive account'); - } - - if (input.amount <= 0) { - throw new Error('Withdrawal amount must be positive'); - } - - if (input.amount > account.balance) { - throw new Error('Insufficient balance'); - } - - if (!input.bankInfo && !input.cryptoInfo) { - throw new Error('Bank or crypto information is required'); - } - - // Check daily withdrawal limit - const dailyLimit = 10000; - const todayWithdrawals = Array.from(withdrawalRequests.values()) - .filter((w) => { - const today = new Date(); - today.setHours(0, 0, 0, 0); - return ( - w.userId === userId && - w.status !== 'rejected' && - w.requestedAt >= today - ); - }) - .reduce((sum, w) => sum + w.amount, 0); - - if (todayWithdrawals + input.amount > dailyLimit) { - throw new Error(`Daily withdrawal limit of $${dailyLimit} exceeded`); - } - - const withdrawal: WithdrawalRequest = { - id: uuidv4(), - accountId: input.accountId, - userId, - amount: input.amount, - status: 'pending', - bankInfo: input.bankInfo || null, - cryptoInfo: input.cryptoInfo || null, - rejectionReason: null, - requestedAt: new Date(), - processedAt: null, - completedAt: null, - }; - - withdrawalRequests.set(withdrawal.id, withdrawal); - - // Create pending transaction - const transaction: Transaction = { - id: uuidv4(), - accountId: input.accountId, - userId, - type: 'withdrawal', - status: 'pending', - amount: -input.amount, - balanceBefore: account.balance, - balanceAfter: account.balance, - stripePaymentId: null, - description: `Pending withdrawal of $${input.amount.toFixed(2)}`, - processedAt: null, - createdAt: new Date(), - }; - - transactions.set(transaction.id, transaction); - - return withdrawal; - } - - /** - * Process a withdrawal (admin) - */ - async processWithdrawal(withdrawalId: string): Promise { - const withdrawal = withdrawalRequests.get(withdrawalId); - if (!withdrawal) { - throw new Error(`Withdrawal not found: ${withdrawalId}`); - } - - if (withdrawal.status !== 'pending') { - throw new Error('Withdrawal is not pending'); - } - - withdrawal.status = 'processing'; - withdrawal.processedAt = new Date(); - - return withdrawal; - } - - /** - * Complete a withdrawal (admin) - */ - async completeWithdrawal(withdrawalId: string): Promise { - const withdrawal = withdrawalRequests.get(withdrawalId); - if (!withdrawal) { - throw new Error(`Withdrawal not found: ${withdrawalId}`); - } - - if (withdrawal.status !== 'processing') { - throw new Error('Withdrawal is not being processed'); - } - - const account = await accountService.getAccountById(withdrawal.accountId); - if (!account) { - throw new Error('Account not found'); - } - - // Deduct from account - account.balance -= withdrawal.amount; - account.totalWithdrawn += withdrawal.amount; - account.updatedAt = new Date(); - - // Update withdrawal - withdrawal.status = 'completed'; - withdrawal.completedAt = new Date(); - - // Update transaction - const pendingTx = Array.from(transactions.values()).find( - (t) => - t.accountId === withdrawal.accountId && - t.type === 'withdrawal' && - t.status === 'pending' && - t.amount === -withdrawal.amount - ); - - if (pendingTx) { - pendingTx.status = 'completed'; - pendingTx.balanceAfter = account.balance; - pendingTx.processedAt = new Date(); - pendingTx.description = `Withdrawal of $${withdrawal.amount.toFixed(2)}`; - } - - return withdrawal; - } - - /** - * Reject a withdrawal (admin) - */ - async rejectWithdrawal( - withdrawalId: string, - reason: string - ): Promise { - const withdrawal = withdrawalRequests.get(withdrawalId); - if (!withdrawal) { - throw new Error(`Withdrawal not found: ${withdrawalId}`); - } - - if (withdrawal.status === 'completed') { - throw new Error('Cannot reject completed withdrawal'); - } - - withdrawal.status = 'rejected'; - withdrawal.rejectionReason = reason; - withdrawal.processedAt = new Date(); - - // Cancel transaction - const pendingTx = Array.from(transactions.values()).find( - (t) => - t.accountId === withdrawal.accountId && - t.type === 'withdrawal' && - t.status === 'pending' && - t.amount === -withdrawal.amount - ); - - if (pendingTx) { - pendingTx.status = 'cancelled'; - pendingTx.description = `Withdrawal rejected: ${reason}`; - } - - return withdrawal; - } - - // ========================================================================== - // Distributions - // ========================================================================== - - /** - * Get distributions for an account - */ - async getAccountDistributions(accountId: string): Promise { - return Array.from(distributions.values()) - .filter((d) => d.accountId === accountId) - .sort((a, b) => b.periodEnd.getTime() - a.periodEnd.getTime()); - } - - /** - * Create a distribution - */ - async createDistribution( - accountId: string, - periodStart: Date, - periodEnd: Date, - grossEarnings: number, - performanceFeePercent: number - ): Promise { - const account = await accountService.getAccountById(accountId); - if (!account) { - throw new Error(`Account not found: ${accountId}`); - } - - const performanceFee = grossEarnings * (performanceFeePercent / 100); - const netEarnings = grossEarnings - performanceFee; - - const distribution: Distribution = { - id: uuidv4(), - accountId, - userId: account.userId, - periodStart, - periodEnd, - grossEarnings, - performanceFee, - netEarnings, - status: 'pending', - distributedAt: null, - createdAt: new Date(), - }; - - distributions.set(distribution.id, distribution); - return distribution; - } - - /** - * Distribute earnings - */ - async distributeEarnings(distributionId: string): Promise { - const distribution = distributions.get(distributionId); - if (!distribution) { - throw new Error(`Distribution not found: ${distributionId}`); - } - - if (distribution.status === 'distributed') { - throw new Error('Distribution already completed'); - } - - // Record earnings in account - await accountService.recordEarnings( - distribution.accountId, - distribution.grossEarnings, - distribution.performanceFee - ); - - // Create transactions - const account = await accountService.getAccountById(distribution.accountId); - - if (distribution.netEarnings !== 0) { - const earningTx: Transaction = { - id: uuidv4(), - accountId: distribution.accountId, - userId: distribution.userId, - type: 'earning', - status: 'completed', - amount: distribution.netEarnings, - balanceBefore: account!.balance - distribution.netEarnings, - balanceAfter: account!.balance, - stripePaymentId: null, - description: `Earnings for ${distribution.periodStart.toLocaleDateString()} - ${distribution.periodEnd.toLocaleDateString()}`, - processedAt: new Date(), - createdAt: new Date(), - }; - transactions.set(earningTx.id, earningTx); - } - - if (distribution.performanceFee > 0) { - const feeTx: Transaction = { - id: uuidv4(), - accountId: distribution.accountId, - userId: distribution.userId, - type: 'fee', - status: 'completed', - amount: -distribution.performanceFee, - balanceBefore: account!.balance, - balanceAfter: account!.balance, - stripePaymentId: null, - description: `Performance fee (${((distribution.performanceFee / distribution.grossEarnings) * 100).toFixed(0)}%)`, - processedAt: new Date(), - createdAt: new Date(), - }; - transactions.set(feeTx.id, feeTx); - } - - distribution.status = 'distributed'; - distribution.distributedAt = new Date(); - - return distribution; - } - - /** - * Get pending distributions - */ - async getPendingDistributions(): Promise { - return Array.from(distributions.values()) - .filter((d) => d.status === 'pending') - .sort((a, b) => a.periodEnd.getTime() - b.periodEnd.getTime()); - } -} - -// Export singleton instance -export const transactionService = new TransactionService(); diff --git a/apps/backend/src/modules/llm/controllers/llm.controller.ts b/apps/backend/src/modules/llm/controllers/llm.controller.ts deleted file mode 100644 index 177c359..0000000 --- a/apps/backend/src/modules/llm/controllers/llm.controller.ts +++ /dev/null @@ -1,260 +0,0 @@ -/** - * LLM Controller - * Handles AI chat assistant endpoints - */ - -import { Request, Response, NextFunction } from 'express'; -import { llmService } from '../services/llm.service'; - -// ============================================================================ -// Types -// ============================================================================ - -// Use Request directly - user is already declared globally in auth.middleware.ts -type AuthRequest = Request; - -// ============================================================================ -// Session Management -// ============================================================================ - -/** - * Create a new chat session - */ -export async function createSession(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const { watchlist, preferences, portfolioSummary } = req.body; - - const session = await llmService.createSession(userId, { - watchlist, - preferences, - portfolioSummary, - }); - - res.status(201).json({ - success: true, - data: { - sessionId: session.id, - createdAt: session.createdAt, - }, - }); - } catch (error) { - next(error); - } -} - -/** - * Get user's chat sessions - */ -export async function getSessions(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const sessions = await llmService.getUserSessions(userId); - - res.json({ - success: true, - data: sessions.map((s) => ({ - id: s.id, - messagesCount: s.messages.length, - lastMessage: s.messages[s.messages.length - 1]?.content.substring(0, 100), - createdAt: s.createdAt, - updatedAt: s.updatedAt, - })), - }); - } catch (error) { - next(error); - } -} - -/** - * Get a specific session with messages - */ -export async function getSession(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const { sessionId } = req.params; - - const session = await llmService.getSession(sessionId); - if (!session) { - res.status(404).json({ - success: false, - error: { message: 'Session not found', code: 'NOT_FOUND' }, - }); - return; - } - - if (session.userId !== userId) { - res.status(403).json({ - success: false, - error: { message: 'Forbidden', code: 'FORBIDDEN' }, - }); - return; - } - - res.json({ - success: true, - data: session, - }); - } catch (error) { - next(error); - } -} - -/** - * Delete a session - */ -export async function deleteSession(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const { sessionId } = req.params; - - const session = await llmService.getSession(sessionId); - if (!session) { - res.status(404).json({ - success: false, - error: { message: 'Session not found', code: 'NOT_FOUND' }, - }); - return; - } - - if (session.userId !== userId) { - res.status(403).json({ - success: false, - error: { message: 'Forbidden', code: 'FORBIDDEN' }, - }); - return; - } - - await llmService.deleteSession(sessionId); - - res.json({ - success: true, - message: 'Session deleted', - }); - } catch (error) { - next(error); - } -} - -// ============================================================================ -// Chat -// ============================================================================ - -/** - * Send a message and get a response - */ -export async function chat(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const { sessionId } = req.params; - const { message } = req.body; - - if (!message || typeof message !== 'string' || message.trim().length === 0) { - res.status(400).json({ - success: false, - error: { message: 'Message is required', code: 'VALIDATION_ERROR' }, - }); - return; - } - - const session = await llmService.getSession(sessionId); - if (!session) { - res.status(404).json({ - success: false, - error: { message: 'Session not found', code: 'NOT_FOUND' }, - }); - return; - } - - if (session.userId !== userId) { - res.status(403).json({ - success: false, - error: { message: 'Forbidden', code: 'FORBIDDEN' }, - }); - return; - } - - const response = await llmService.chat(sessionId, message.trim()); - - res.json({ - success: true, - data: response, - }); - } catch (error) { - next(error); - } -} - -// ============================================================================ -// Quick Actions -// ============================================================================ - -/** - * Get quick analysis for a symbol (no session required) - */ -export async function getQuickAnalysis(req: Request, res: Response, next: NextFunction): Promise { - try { - const { symbol } = req.params; - - if (!symbol) { - res.status(400).json({ - success: false, - error: { message: 'Symbol is required', code: 'VALIDATION_ERROR' }, - }); - return; - } - - const analysis = await llmService.getQuickAnalysis(symbol.toUpperCase()); - - res.json({ - success: true, - data: { - symbol: symbol.toUpperCase(), - analysis, - timestamp: new Date().toISOString(), - }, - }); - } catch (error) { - next(error); - } -} diff --git a/apps/backend/src/modules/llm/llm.routes.ts b/apps/backend/src/modules/llm/llm.routes.ts deleted file mode 100644 index 8cfcd82..0000000 --- a/apps/backend/src/modules/llm/llm.routes.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * LLM Routes - * AI chat assistant endpoints - */ - -import { Router, RequestHandler } from 'express'; -import * as llmController from './controllers/llm.controller'; - -const router = Router(); - -// Type cast helper for authenticated routes -// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -const authHandler = (fn: Function): RequestHandler => fn as RequestHandler; - -// ============================================================================ -// Session Management (Authenticated) -// ============================================================================ - -/** - * POST /api/v1/llm/sessions - * Create a new chat session - * Body: { watchlist?, preferences?, portfolioSummary? } - */ -router.post('/sessions', authHandler(llmController.createSession)); - -/** - * GET /api/v1/llm/sessions - * Get user's chat sessions - */ -router.get('/sessions', authHandler(llmController.getSessions)); - -/** - * GET /api/v1/llm/sessions/:sessionId - * Get a specific session with messages - */ -router.get('/sessions/:sessionId', authHandler(llmController.getSession)); - -/** - * DELETE /api/v1/llm/sessions/:sessionId - * Delete a session - */ -router.delete('/sessions/:sessionId', authHandler(llmController.deleteSession)); - -// ============================================================================ -// Chat (Authenticated) -// ============================================================================ - -/** - * POST /api/v1/llm/sessions/:sessionId/chat - * Send a message and get a response - * Body: { message: string } - */ -router.post('/sessions/:sessionId/chat', authHandler(llmController.chat)); - -// ============================================================================ -// Quick Actions (Public) -// ============================================================================ - -/** - * GET /api/v1/llm/analyze/:symbol - * Get quick analysis for a symbol - */ -router.get('/analyze/:symbol', llmController.getQuickAnalysis); - -export { router as llmRouter }; diff --git a/apps/backend/src/modules/llm/services/llm.service.ts b/apps/backend/src/modules/llm/services/llm.service.ts deleted file mode 100644 index 8143d23..0000000 --- a/apps/backend/src/modules/llm/services/llm.service.ts +++ /dev/null @@ -1,494 +0,0 @@ -/** - * LLM Service - * AI-powered trading assistant using Claude or OpenAI - */ - -import Anthropic from '@anthropic-ai/sdk'; -import { v4 as uuidv4 } from 'uuid'; -import { mlIntegrationService } from '../../ml/services/ml-integration.service'; -import { marketService } from '../../trading/services/market.service'; - -// ============================================================================ -// Types -// ============================================================================ - -export type MessageRole = 'user' | 'assistant' | 'system'; - -export interface ChatMessage { - id: string; - role: MessageRole; - content: string; - timestamp: Date; - metadata?: { - toolCalls?: ToolCall[]; - signal?: unknown; - error?: string; - }; -} - -export interface ChatSession { - id: string; - userId: string; - messages: ChatMessage[]; - context: SessionContext; - createdAt: Date; - updatedAt: Date; -} - -export interface SessionContext { - watchlist: string[]; - preferences: { - riskProfile: 'conservative' | 'moderate' | 'aggressive'; - preferredTimeframe: string; - language: 'es' | 'en'; - }; - recentSignals: unknown[]; - portfolioSummary?: unknown; -} - -export interface ToolCall { - name: string; - input: Record; - output?: unknown; -} - -export interface LLMConfig { - provider: 'anthropic' | 'openai'; - model: string; - maxTokens: number; - temperature: number; -} - -// ============================================================================ -// Tool Definitions -// ============================================================================ - -const TRADING_TOOLS = [ - { - name: 'get_signal', - description: 'Get the current ML trading signal for a cryptocurrency symbol', - input_schema: { - type: 'object', - properties: { - symbol: { - type: 'string', - description: 'The trading pair symbol (e.g., BTCUSDT)', - }, - timeHorizon: { - type: 'string', - enum: ['scalp', 'intraday', 'swing'], - description: 'The trading time horizon', - }, - }, - required: ['symbol'], - }, - }, - { - name: 'get_price', - description: 'Get the current price for a cryptocurrency symbol', - input_schema: { - type: 'object', - properties: { - symbol: { - type: 'string', - description: 'The trading pair symbol (e.g., BTCUSDT)', - }, - }, - required: ['symbol'], - }, - }, - { - name: 'get_indicators', - description: 'Get technical indicators for a symbol (RSI, MACD, etc.)', - input_schema: { - type: 'object', - properties: { - symbol: { - type: 'string', - description: 'The trading pair symbol', - }, - }, - required: ['symbol'], - }, - }, - { - name: 'analyze_chart', - description: 'Get a technical analysis summary for a symbol', - input_schema: { - type: 'object', - properties: { - symbol: { - type: 'string', - description: 'The trading pair symbol', - }, - timeframe: { - type: 'string', - description: 'The chart timeframe (1h, 4h, 1d)', - }, - }, - required: ['symbol'], - }, - }, - { - name: 'get_amd_phase', - description: 'Get the current AMD (Accumulation/Manipulation/Distribution) phase', - input_schema: { - type: 'object', - properties: { - symbol: { - type: 'string', - description: 'The trading pair symbol', - }, - }, - required: ['symbol'], - }, - }, -]; - -// ============================================================================ -// System Prompts -// ============================================================================ - -const SYSTEM_PROMPT = `Eres un asistente de trading experto para la plataforma OrbiQuant IA. Tu rol es: - -1. **Interpretar señales ML**: Explicar las señales del modelo de machine learning en lenguaje claro. -2. **Recomendar estrategias**: Sugerir puntos de entrada/salida basados en el análisis técnico. -3. **Educar al usuario**: Explicar conceptos de trading cuando sea necesario. -4. **Gestionar riesgo**: Siempre mencionar stop loss y take profit cuando sugieras trades. - -## Personalidad -- Profesional pero accesible -- Directo y conciso -- Siempre menciona el nivel de confianza de las predicciones -- Nunca prometas ganancias garantizadas -- Incluye disclaimers de riesgo cuando sea apropiado - -## Formato de Respuestas -- Usa emojis para señales: 📈 (buy), 📉 (sell), ⚖️ (hold) -- Estructura las recomendaciones claramente -- Incluye niveles de precio específicos cuando des recomendaciones -- Usa formato markdown para mejor legibilidad - -## Restricciones -- No ejecutes trades directamente, solo sugiere -- No proporciones asesoría financiera personal -- Siempre aclara que las predicciones son probabilísticas -- Recuerda al usuario que el trading conlleva riesgos`; - -// ============================================================================ -// In-Memory Storage -// ============================================================================ - -const sessions: Map = new Map(); - -// ============================================================================ -// LLM Service -// ============================================================================ - -class LLMService { - private anthropic: Anthropic | null = null; - private config: LLMConfig; - - constructor() { - this.config = { - provider: 'anthropic', - model: 'claude-3-5-sonnet-20241022', - maxTokens: 4096, - temperature: 0.7, - }; - - // Initialize Anthropic client if API key is available - const apiKey = process.env.ANTHROPIC_API_KEY; - if (apiKey) { - this.anthropic = new Anthropic({ apiKey }); - } - } - - // ========================================================================== - // Session Management - // ========================================================================== - - /** - * Create a new chat session - */ - async createSession( - userId: string, - context?: Partial - ): Promise { - const session: ChatSession = { - id: uuidv4(), - userId, - messages: [], - context: { - watchlist: context?.watchlist || ['BTCUSDT', 'ETHUSDT'], - preferences: { - riskProfile: context?.preferences?.riskProfile || 'moderate', - preferredTimeframe: context?.preferences?.preferredTimeframe || '1h', - language: context?.preferences?.language || 'es', - }, - recentSignals: [], - portfolioSummary: context?.portfolioSummary, - }, - createdAt: new Date(), - updatedAt: new Date(), - }; - - sessions.set(session.id, session); - return session; - } - - /** - * Get a session by ID - */ - async getSession(sessionId: string): Promise { - return sessions.get(sessionId) || null; - } - - /** - * Get sessions for a user - */ - async getUserSessions(userId: string): Promise { - return Array.from(sessions.values()) - .filter((s) => s.userId === userId) - .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()); - } - - /** - * Delete a session - */ - async deleteSession(sessionId: string): Promise { - return sessions.delete(sessionId); - } - - // ========================================================================== - // Chat - // ========================================================================== - - /** - * Send a message and get a response - */ - async chat( - sessionId: string, - userMessage: string - ): Promise { - const session = sessions.get(sessionId); - if (!session) { - throw new Error(`Session not found: ${sessionId}`); - } - - // Add user message to session - const userMsg: ChatMessage = { - id: uuidv4(), - role: 'user', - content: userMessage, - timestamp: new Date(), - }; - session.messages.push(userMsg); - - // Generate response - let response: ChatMessage; - try { - if (this.anthropic) { - response = await this.generateAnthropicResponse(session); - } else { - response = await this.generateMockResponse(session, userMessage); - } - } catch (error) { - response = { - id: uuidv4(), - role: 'assistant', - content: 'Lo siento, hubo un error procesando tu solicitud. Por favor, intenta de nuevo.', - timestamp: new Date(), - metadata: { - error: error instanceof Error ? error.message : 'Unknown error', - }, - }; - } - - // Add response to session - session.messages.push(response); - session.updatedAt = new Date(); - - return response; - } - - /** - * Get quick analysis for a symbol - */ - async getQuickAnalysis(symbol: string): Promise { - try { - const [signal, indicators, price] = await Promise.all([ - mlIntegrationService.getSignal(symbol).catch(() => null), - mlIntegrationService.getIndicators(symbol).catch(() => null), - marketService.getPrice(symbol).catch(() => null), - ]); - - if (!price) { - return `No se pudo obtener información para ${symbol}`; - } - - let analysis = `## Análisis Rápido: ${symbol}\n\n`; - analysis += `**Precio Actual:** $${price.price.toLocaleString()}\n\n`; - - if (signal) { - const emoji = signal.signalType === 'buy' ? '📈' : signal.signalType === 'sell' ? '📉' : '⚖️'; - analysis += `${emoji} **Señal:** ${signal.signalType.toUpperCase()} (Confianza: ${(signal.confidence * 100).toFixed(1)}%)\n`; - analysis += `**Fase AMD:** ${signal.amdPhase}\n\n`; - } - - if (indicators) { - analysis += `### Indicadores Técnicos\n`; - analysis += `- **RSI:** ${indicators.rsi.toFixed(1)} ${indicators.rsi > 70 ? '(Sobrecompra)' : indicators.rsi < 30 ? '(Sobreventa)' : '(Neutral)'}\n`; - analysis += `- **MACD:** ${indicators.macd.histogram > 0 ? 'Alcista' : 'Bajista'}\n`; - analysis += `- **ATR:** ${indicators.atrPercent.toFixed(2)}% (Volatilidad)\n`; - } - - return analysis; - } catch { - return `Error al analizar ${symbol}`; - } - } - - // ========================================================================== - // Private Methods - // ========================================================================== - - private async generateAnthropicResponse(session: ChatSession): Promise { - if (!this.anthropic) { - throw new Error('Anthropic client not initialized'); - } - - // Build messages for API - const messages = session.messages.map((m) => ({ - role: m.role as 'user' | 'assistant', - content: m.content, - })); - - // Call Anthropic API with tools - const response = await this.anthropic.messages.create({ - model: this.config.model, - max_tokens: this.config.maxTokens, - temperature: this.config.temperature, - system: SYSTEM_PROMPT, - messages, - tools: TRADING_TOOLS as Anthropic.Tool[], - }); - - // Process tool calls if any - const toolCalls: ToolCall[] = []; - let finalContent = ''; - - for (const block of response.content) { - if (block.type === 'text') { - finalContent += block.text; - } else if (block.type === 'tool_use') { - const toolResult = await this.executeTool(block.name, block.input as Record); - toolCalls.push({ - name: block.name, - input: block.input as Record, - output: toolResult, - }); - } - } - - // If there were tool calls, make a follow-up request - if (toolCalls.length > 0 && response.stop_reason === 'tool_use') { - const toolResults = toolCalls.map((tc) => ({ - type: 'tool_result' as const, - tool_use_id: uuidv4(), - content: JSON.stringify(tc.output), - })); - - const followUp = await this.anthropic.messages.create({ - model: this.config.model, - max_tokens: this.config.maxTokens, - system: SYSTEM_PROMPT, - messages: [ - ...messages, - { role: 'assistant', content: response.content }, - { role: 'user', content: toolResults }, - ], - }); - - for (const block of followUp.content) { - if (block.type === 'text') { - finalContent = block.text; - } - } - } - - return { - id: uuidv4(), - role: 'assistant', - content: finalContent, - timestamp: new Date(), - metadata: { - toolCalls: toolCalls.length > 0 ? toolCalls : undefined, - }, - }; - } - - private async generateMockResponse( - session: ChatSession, - userMessage: string - ): Promise { - // Simple mock response for development without API key - const lowerMessage = userMessage.toLowerCase(); - let response = ''; - - if (lowerMessage.includes('bitcoin') || lowerMessage.includes('btc')) { - response = await this.getQuickAnalysis('BTCUSDT'); - } else if (lowerMessage.includes('ethereum') || lowerMessage.includes('eth')) { - response = await this.getQuickAnalysis('ETHUSDT'); - } else if (lowerMessage.includes('señal') || lowerMessage.includes('signal')) { - response = `Para obtener una señal, especifica el símbolo. Por ejemplo: "¿Cuál es la señal para Bitcoin?"`; - } else if (lowerMessage.includes('hola') || lowerMessage.includes('hello')) { - response = `¡Hola! Soy tu asistente de trading de OrbiQuant. Puedo ayudarte a:\n\n- 📊 Analizar criptomonedas\n- 📈 Interpretar señales de trading\n- 📚 Explicar conceptos de trading\n\n¿Qué te gustaría saber?`; - } else { - response = `Entiendo tu consulta. Para darte información precisa, puedes preguntarme sobre:\n\n- Señales de trading (ej: "¿Cuál es la señal para BTC?")\n- Análisis técnico (ej: "Analiza ETH")\n- Conceptos de trading (ej: "¿Qué es AMD?")\n\n¿En qué puedo ayudarte?`; - } - - return { - id: uuidv4(), - role: 'assistant', - content: response, - timestamp: new Date(), - }; - } - - private async executeTool( - name: string, - input: Record - ): Promise { - switch (name) { - case 'get_signal': - return mlIntegrationService.getSignal( - input.symbol as string, - (input.timeHorizon as 'scalp' | 'intraday' | 'swing') || 'intraday' - ); - - case 'get_price': - return marketService.getPrice(input.symbol as string); - - case 'get_indicators': - return mlIntegrationService.getIndicators(input.symbol as string); - - case 'analyze_chart': { - const [signal, indicators] = await Promise.all([ - mlIntegrationService.getSignal(input.symbol as string), - mlIntegrationService.getIndicators(input.symbol as string), - ]); - return { signal, indicators }; - } - - case 'get_amd_phase': - return mlIntegrationService.getAMDPhase(input.symbol as string); - - default: - throw new Error(`Unknown tool: ${name}`); - } - } -} - -// Export singleton instance -export const llmService = new LLMService(); diff --git a/apps/backend/src/modules/ml/controllers/ml-overlay.controller.ts b/apps/backend/src/modules/ml/controllers/ml-overlay.controller.ts deleted file mode 100644 index 90fc88e..0000000 --- a/apps/backend/src/modules/ml/controllers/ml-overlay.controller.ts +++ /dev/null @@ -1,248 +0,0 @@ -/** - * ML Overlay Controller - * Handles chart overlay endpoints for trading visualization - */ - -import { Request, Response, NextFunction } from 'express'; -import { mlOverlayService, OverlayConfig } from '../services/ml-overlay.service'; - -// ============================================================================ -// Overlay Endpoints -// ============================================================================ - -/** - * Get complete chart overlay for a symbol - */ -export async function getChartOverlay( - req: Request, - res: Response, - next: NextFunction -): Promise { - try { - const { symbol } = req.params; - const config = parseOverlayConfig(req.query); - - const overlay = await mlOverlayService.getChartOverlay(symbol.toUpperCase(), config); - - res.json({ - success: true, - data: overlay, - }); - } catch (error) { - next(error); - } -} - -/** - * Get overlays for multiple symbols - */ -export async function getBatchOverlays( - req: Request, - res: Response, - next: NextFunction -): Promise { - try { - const { symbols } = req.body; - const config = parseOverlayConfig(req.body); - - if (!symbols || !Array.isArray(symbols) || symbols.length === 0) { - res.status(400).json({ - success: false, - error: { message: 'Symbols array is required', code: 'VALIDATION_ERROR' }, - }); - return; - } - - if (symbols.length > 20) { - res.status(400).json({ - success: false, - error: { message: 'Maximum 20 symbols allowed per request', code: 'VALIDATION_ERROR' }, - }); - return; - } - - const overlays = await mlOverlayService.getBatchOverlays( - symbols.map((s: string) => s.toUpperCase()), - config - ); - - // Convert Map to object for JSON response - const result: Record = {}; - overlays.forEach((value, key) => { - result[key] = value; - }); - - res.json({ - success: true, - data: result, - }); - } catch (error) { - next(error); - } -} - -/** - * Get price levels only - */ -export async function getPriceLevels( - req: Request, - res: Response, - next: NextFunction -): Promise { - try { - const { symbol } = req.params; - - const levels = await mlOverlayService.getPriceLevels(symbol.toUpperCase()); - - res.json({ - success: true, - data: { - symbol: symbol.toUpperCase(), - levels, - timestamp: new Date().toISOString(), - }, - }); - } catch (error) { - next(error); - } -} - -/** - * Get signal markers - */ -export async function getSignalMarkers( - req: Request, - res: Response, - next: NextFunction -): Promise { - try { - const { symbol } = req.params; - const limit = Math.min(Number(req.query.limit) || 20, 100); - - const markers = await mlOverlayService.getSignalMarkers(symbol.toUpperCase(), limit); - - res.json({ - success: true, - data: { - symbol: symbol.toUpperCase(), - markers, - timestamp: new Date().toISOString(), - }, - }); - } catch (error) { - next(error); - } -} - -/** - * Get AMD phase overlay - */ -export async function getAMDPhaseOverlay( - req: Request, - res: Response, - next: NextFunction -): Promise { - try { - const { symbol } = req.params; - - const amdPhase = await mlOverlayService.getAMDPhaseOverlay(symbol.toUpperCase()); - - res.json({ - success: true, - data: { - symbol: symbol.toUpperCase(), - ...amdPhase, - timestamp: new Date().toISOString(), - }, - }); - } catch (error) { - next(error); - } -} - -/** - * Get prediction bands - */ -export async function getPredictionBands( - req: Request, - res: Response, - next: NextFunction -): Promise { - try { - const { symbol } = req.params; - const horizonMinutes = Math.min(Number(req.query.horizon) || 90, 480); - const intervals = Math.min(Number(req.query.intervals) || 10, 50); - - const bands = await mlOverlayService.getPredictionBands( - symbol.toUpperCase(), - horizonMinutes, - intervals - ); - - res.json({ - success: true, - data: { - symbol: symbol.toUpperCase(), - horizonMinutes, - bands, - timestamp: new Date().toISOString(), - }, - }); - } catch (error) { - next(error); - } -} - -/** - * Clear overlay cache - */ -export async function clearCache( - req: Request, - res: Response, - next: NextFunction -): Promise { - try { - const { symbol } = req.params; - - mlOverlayService.clearCache(symbol?.toUpperCase()); - - res.json({ - success: true, - message: symbol ? `Cache cleared for ${symbol.toUpperCase()}` : 'All cache cleared', - }); - } catch (error) { - next(error); - } -} - -// ============================================================================ -// Helper Functions -// ============================================================================ - -function parseOverlayConfig(query: Record): Partial { - const config: Partial = {}; - - if (query.showPriceLevels !== undefined) { - config.showPriceLevels = query.showPriceLevels === 'true' || query.showPriceLevels === true; - } - if (query.showTrendLines !== undefined) { - config.showTrendLines = query.showTrendLines === 'true' || query.showTrendLines === true; - } - if (query.showSignalMarkers !== undefined) { - config.showSignalMarkers = query.showSignalMarkers === 'true' || query.showSignalMarkers === true; - } - if (query.showZones !== undefined) { - config.showZones = query.showZones === 'true' || query.showZones === true; - } - if (query.showPredictionBands !== undefined) { - config.showPredictionBands = query.showPredictionBands === 'true' || query.showPredictionBands === true; - } - if (query.showIndicators !== undefined) { - config.showIndicators = query.showIndicators === 'true' || query.showIndicators === true; - } - if (query.showAMDPhase !== undefined) { - config.showAMDPhase = query.showAMDPhase === 'true' || query.showAMDPhase === true; - } - - return config; -} diff --git a/apps/backend/src/modules/ml/controllers/ml.controller.ts b/apps/backend/src/modules/ml/controllers/ml.controller.ts deleted file mode 100644 index 0a8861d..0000000 --- a/apps/backend/src/modules/ml/controllers/ml.controller.ts +++ /dev/null @@ -1,301 +0,0 @@ -/** - * ML Controller - * Handles ML Engine integration endpoints - */ - -import { Request, Response, NextFunction } from 'express'; -import { - mlIntegrationService, - TimeHorizon, - SignalType, -} from '../services/ml-integration.service'; - -// ============================================================================ -// Types -// ============================================================================ - -// Use Request directly - user is already declared globally in auth.middleware.ts -type AuthRequest = Request; - -// ============================================================================ -// Health & Status -// ============================================================================ - -/** - * Get ML Engine health status - */ -export async function getHealth(req: Request, res: Response, next: NextFunction): Promise { - try { - const health = await mlIntegrationService.getHealth(); - - res.json({ - success: true, - data: health, - }); - } catch (error) { - next(error); - } -} - -/** - * Check ML Engine connection - */ -export async function checkConnection(req: Request, res: Response, next: NextFunction): Promise { - try { - const isConnected = await mlIntegrationService.checkConnection(); - - res.json({ - success: true, - data: { connected: isConnected }, - }); - } catch (error) { - next(error); - } -} - -// ============================================================================ -// Signals -// ============================================================================ - -/** - * Get trading signal for a symbol - */ -export async function getSignal(req: Request, res: Response, next: NextFunction): Promise { - try { - const { symbol } = req.params; - const { timeHorizon = 'intraday' } = req.query; - - const signal = await mlIntegrationService.getSignal( - symbol, - timeHorizon as TimeHorizon - ); - - res.json({ - success: true, - data: signal, - }); - } catch (error) { - next(error); - } -} - -/** - * Get signals for multiple symbols - */ -export async function getSignals(req: Request, res: Response, next: NextFunction): Promise { - try { - const { symbols, timeHorizon = 'intraday' } = req.body; - - if (!symbols || !Array.isArray(symbols) || symbols.length === 0) { - res.status(400).json({ - success: false, - error: { message: 'Symbols array is required', code: 'VALIDATION_ERROR' }, - }); - return; - } - - const signals = await mlIntegrationService.getSignals( - symbols, - timeHorizon as TimeHorizon - ); - - res.json({ - success: true, - data: signals, - }); - } catch (error) { - next(error); - } -} - -/** - * Get historical signals - */ -export async function getHistoricalSignals(req: Request, res: Response, next: NextFunction): Promise { - try { - const { symbol } = req.params; - const { startTime, endTime, limit, signalType } = req.query; - - const signals = await mlIntegrationService.getHistoricalSignals(symbol, { - startTime: startTime ? new Date(startTime as string) : undefined, - endTime: endTime ? new Date(endTime as string) : undefined, - limit: limit ? Number(limit) : undefined, - signalType: signalType as SignalType | undefined, - }); - - res.json({ - success: true, - data: signals, - }); - } catch (error) { - next(error); - } -} - -// ============================================================================ -// Predictions -// ============================================================================ - -/** - * Get price prediction - */ -export async function getPrediction(req: Request, res: Response, next: NextFunction): Promise { - try { - const { symbol } = req.params; - const { horizonMinutes = 90 } = req.query; - - const prediction = await mlIntegrationService.getPrediction( - symbol, - Number(horizonMinutes) - ); - - res.json({ - success: true, - data: prediction, - }); - } catch (error) { - next(error); - } -} - -/** - * Get AMD phase prediction - */ -export async function getAMDPhase(req: Request, res: Response, next: NextFunction): Promise { - try { - const { symbol } = req.params; - - const amdPhase = await mlIntegrationService.getAMDPhase(symbol); - - res.json({ - success: true, - data: amdPhase, - }); - } catch (error) { - next(error); - } -} - -// ============================================================================ -// Indicators -// ============================================================================ - -/** - * Get technical indicators - */ -export async function getIndicators(req: Request, res: Response, next: NextFunction): Promise { - try { - const { symbol } = req.params; - - const indicators = await mlIntegrationService.getIndicators(symbol); - - res.json({ - success: true, - data: indicators, - }); - } catch (error) { - next(error); - } -} - -// ============================================================================ -// Backtesting -// ============================================================================ - -/** - * Run backtest - */ -export async function runBacktest(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const { symbol, startDate, endDate, initialCapital, strategy, params } = req.body; - - if (!symbol || !startDate || !endDate) { - res.status(400).json({ - success: false, - error: { message: 'Symbol, startDate, and endDate are required', code: 'VALIDATION_ERROR' }, - }); - return; - } - - const result = await mlIntegrationService.runBacktest(symbol, { - startDate: new Date(startDate), - endDate: new Date(endDate), - initialCapital, - strategy, - params, - }); - - res.json({ - success: true, - data: result, - }); - } catch (error) { - next(error); - } -} - -// ============================================================================ -// Models (Admin) -// ============================================================================ - -/** - * Get available models - */ -export async function getModels(req: Request, res: Response, next: NextFunction): Promise { - try { - const models = await mlIntegrationService.getModels(); - - res.json({ - success: true, - data: models, - }); - } catch (error) { - next(error); - } -} - -/** - * Trigger model retraining - */ -export async function triggerRetraining(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - // TODO: Add admin check - const { symbol } = req.body; - - const result = await mlIntegrationService.triggerRetraining(symbol); - - res.json({ - success: true, - data: result, - }); - } catch (error) { - next(error); - } -} - -/** - * Get retraining job status - */ -export async function getRetrainingStatus(req: Request, res: Response, next: NextFunction): Promise { - try { - const { jobId } = req.params; - - const status = await mlIntegrationService.getRetrainingStatus(jobId); - - res.json({ - success: true, - data: status, - }); - } catch (error) { - next(error); - } -} diff --git a/apps/backend/src/modules/ml/ml.routes.ts b/apps/backend/src/modules/ml/ml.routes.ts deleted file mode 100644 index a8c6352..0000000 --- a/apps/backend/src/modules/ml/ml.routes.ts +++ /dev/null @@ -1,168 +0,0 @@ -/** - * ML Routes - * ML Engine integration endpoints - */ - -import { Router, RequestHandler } from 'express'; -import * as mlController from './controllers/ml.controller'; -import * as mlOverlayController from './controllers/ml-overlay.controller'; - -const router = Router(); - -// Type cast helper for authenticated routes -// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -const authHandler = (fn: Function): RequestHandler => fn as RequestHandler; - -// ============================================================================ -// Health & Status -// ============================================================================ - -/** - * GET /api/v1/ml/health - * Get ML Engine health status - */ -router.get('/health', mlController.getHealth); - -/** - * GET /api/v1/ml/connection - * Check ML Engine connection - */ -router.get('/connection', mlController.checkConnection); - -// ============================================================================ -// Signals -// ============================================================================ - -/** - * GET /api/v1/ml/signals/:symbol - * Get trading signal for a symbol - * Query params: timeHorizon (scalp, intraday, swing) - */ -router.get('/signals/:symbol', mlController.getSignal); - -/** - * POST /api/v1/ml/signals/batch - * Get signals for multiple symbols - * Body: { symbols: string[], timeHorizon?: string } - */ -router.post('/signals/batch', mlController.getSignals); - -/** - * GET /api/v1/ml/signals/:symbol/history - * Get historical signals - * Query params: startTime, endTime, limit, signalType - */ -router.get('/signals/:symbol/history', mlController.getHistoricalSignals); - -// ============================================================================ -// Predictions -// ============================================================================ - -/** - * GET /api/v1/ml/predictions/:symbol - * Get price prediction - * Query params: horizonMinutes - */ -router.get('/predictions/:symbol', mlController.getPrediction); - -/** - * GET /api/v1/ml/amd/:symbol - * Get AMD phase prediction - */ -router.get('/amd/:symbol', mlController.getAMDPhase); - -// ============================================================================ -// Indicators -// ============================================================================ - -/** - * GET /api/v1/ml/indicators/:symbol - * Get technical indicators - */ -router.get('/indicators/:symbol', mlController.getIndicators); - -// ============================================================================ -// Backtesting (Authenticated) -// ============================================================================ - -/** - * POST /api/v1/ml/backtest - * Run backtest - * Body: { symbol, startDate, endDate, initialCapital?, strategy?, params? } - */ -router.post('/backtest', authHandler(mlController.runBacktest)); - -// ============================================================================ -// Models (Admin) -// ============================================================================ - -/** - * GET /api/v1/ml/models - * Get available models - */ -router.get('/models', mlController.getModels); - -/** - * POST /api/v1/ml/models/retrain - * Trigger model retraining - * Body: { symbol?: string } - */ -router.post('/models/retrain', authHandler(mlController.triggerRetraining)); - -/** - * GET /api/v1/ml/models/retrain/:jobId - * Get retraining job status - */ -router.get('/models/retrain/:jobId', mlController.getRetrainingStatus); - -// ============================================================================ -// Chart Overlays -// ============================================================================ - -/** - * GET /api/v1/ml/overlays/:symbol - * Get complete chart overlay for a symbol - * Query params: showPriceLevels, showTrendLines, showSignalMarkers, showZones, showIndicators, showAMDPhase - */ -router.get('/overlays/:symbol', mlOverlayController.getChartOverlay); - -/** - * POST /api/v1/ml/overlays/batch - * Get overlays for multiple symbols - * Body: { symbols: string[], ...config } - */ -router.post('/overlays/batch', mlOverlayController.getBatchOverlays); - -/** - * GET /api/v1/ml/overlays/:symbol/levels - * Get price levels only - */ -router.get('/overlays/:symbol/levels', mlOverlayController.getPriceLevels); - -/** - * GET /api/v1/ml/overlays/:symbol/signals - * Get signal markers - * Query params: limit - */ -router.get('/overlays/:symbol/signals', mlOverlayController.getSignalMarkers); - -/** - * GET /api/v1/ml/overlays/:symbol/amd - * Get AMD phase overlay - */ -router.get('/overlays/:symbol/amd', mlOverlayController.getAMDPhaseOverlay); - -/** - * GET /api/v1/ml/overlays/:symbol/predictions - * Get prediction bands - * Query params: horizon, intervals - */ -router.get('/overlays/:symbol/predictions', mlOverlayController.getPredictionBands); - -/** - * DELETE /api/v1/ml/overlays/cache/:symbol? - * Clear overlay cache - */ -router.delete('/overlays/cache{/:symbol}', mlOverlayController.clearCache); - -export { router as mlRouter }; diff --git a/apps/backend/src/modules/ml/services/ml-integration.service.ts b/apps/backend/src/modules/ml/services/ml-integration.service.ts deleted file mode 100644 index 08d4d03..0000000 --- a/apps/backend/src/modules/ml/services/ml-integration.service.ts +++ /dev/null @@ -1,538 +0,0 @@ -/** - * ML Integration Service - * Connects to the FastAPI ML Engine for trading signals and predictions - */ - -import axios, { AxiosInstance, AxiosError } from 'axios'; -import { EventEmitter } from 'events'; - -// ============================================================================ -// Types -// ============================================================================ - -export type SignalType = 'buy' | 'sell' | 'hold'; -export type TimeHorizon = 'scalp' | 'intraday' | 'swing'; -export type AMDPhase = 'accumulation' | 'manipulation' | 'distribution' | 'unknown'; - -export interface MLSignal { - id: string; - symbol: string; - timestamp: Date; - signalType: SignalType; - confidence: number; - timeHorizon: TimeHorizon; - amdPhase: AMDPhase; - indicators: MLIndicators; - prediction: MLPrediction; - reasoning: string; - expiresAt: Date; -} - -export interface MLIndicators { - rsi: number; - macd: { - value: number; - signal: number; - histogram: number; - }; - atr: number; - atrPercent: number; - volumeRatio: number; - ema20: number; - ema50: number; - ema200: number; - bollingerBands: { - upper: number; - middle: number; - lower: number; - width: number; - percentB: number; - }; - supportLevels: number[]; - resistanceLevels: number[]; -} - -export interface MLPrediction { - targetPrice: number; - expectedHigh: number; - expectedLow: number; - stopLoss: number; - takeProfit: number; - riskRewardRatio: number; - probabilityUp: number; - probabilityDown: number; - volatilityForecast: number; -} - -export interface ModelHealth { - status: 'healthy' | 'degraded' | 'unhealthy'; - lastTraining: Date; - accuracy: number; - precision: number; - recall: number; - f1Score: number; - totalPredictions: number; - correctPredictions: number; - uptime: number; -} - -export interface BacktestResult { - symbol: string; - startDate: Date; - endDate: Date; - initialCapital: number; - finalCapital: number; - totalReturn: number; - annualizedReturn: number; - maxDrawdown: number; - sharpeRatio: number; - winRate: number; - totalTrades: number; - profitFactor: number; - trades: BacktestTrade[]; -} - -export interface BacktestTrade { - entryTime: Date; - exitTime: Date; - side: 'long' | 'short'; - entryPrice: number; - exitPrice: number; - quantity: number; - pnl: number; - pnlPercent: number; - exitReason: 'take_profit' | 'stop_loss' | 'signal' | 'expiry'; -} - -export interface MLEngineConfig { - baseUrl: string; - apiKey?: string; - timeout: number; - retryAttempts: number; - retryDelay: number; -} - -// ============================================================================ -// Default Configuration -// ============================================================================ - -const DEFAULT_CONFIG: MLEngineConfig = { - baseUrl: process.env.ML_ENGINE_URL || 'http://localhost:8000', - apiKey: process.env.ML_ENGINE_API_KEY, - timeout: 30000, - retryAttempts: 3, - retryDelay: 1000, -}; - -// ============================================================================ -// ML Integration Service -// ============================================================================ - -class MLIntegrationService extends EventEmitter { - private client: AxiosInstance; - private config: MLEngineConfig; - private isConnected: boolean = false; - private reconnectInterval: NodeJS.Timeout | null = null; - - constructor(config: Partial = {}) { - super(); - this.config = { ...DEFAULT_CONFIG, ...config }; - - this.client = axios.create({ - baseURL: this.config.baseUrl, - timeout: this.config.timeout, - headers: { - 'Content-Type': 'application/json', - ...(this.config.apiKey && { 'X-API-Key': this.config.apiKey }), - }, - }); - - // Add response interceptor for error handling - this.client.interceptors.response.use( - (response) => response, - (error: AxiosError) => this.handleError(error) - ); - } - - // ========================================================================== - // Connection Management - // ========================================================================== - - /** - * Check ML Engine connection - */ - async checkConnection(): Promise { - try { - const response = await this.client.get('/health'); - this.isConnected = response.data.status === 'healthy'; - this.emit('connectionStatus', this.isConnected); - return this.isConnected; - } catch { - this.isConnected = false; - this.emit('connectionStatus', false); - return false; - } - } - - /** - * Get ML Engine health status - */ - async getHealth(): Promise { - const response = await this.client.get('/health'); - return { - status: response.data.status, - lastTraining: new Date(response.data.last_training), - accuracy: response.data.accuracy, - precision: response.data.precision, - recall: response.data.recall, - f1Score: response.data.f1_score, - totalPredictions: response.data.total_predictions, - correctPredictions: response.data.correct_predictions, - uptime: response.data.uptime, - }; - } - - /** - * Start reconnection monitoring - */ - startReconnectMonitor(intervalMs: number = 30000): void { - if (this.reconnectInterval) return; - - this.reconnectInterval = setInterval(async () => { - if (!this.isConnected) { - console.log('[MLService] Attempting to reconnect to ML Engine...'); - await this.checkConnection(); - } - }, intervalMs); - } - - /** - * Stop reconnection monitoring - */ - stopReconnectMonitor(): void { - if (this.reconnectInterval) { - clearInterval(this.reconnectInterval); - this.reconnectInterval = null; - } - } - - // ========================================================================== - // Signal Generation - // ========================================================================== - - /** - * Get current trading signal for a symbol - */ - async getSignal(symbol: string, timeHorizon: TimeHorizon = 'intraday'): Promise { - const response = await this.client.get(`/signals/${symbol}`, { - params: { time_horizon: timeHorizon }, - }); - - return this.transformSignal(response.data); - } - - /** - * Get signals for multiple symbols - */ - async getSignals( - symbols: string[], - timeHorizon: TimeHorizon = 'intraday' - ): Promise { - const response = await this.client.post('/signals/batch', { - symbols, - time_horizon: timeHorizon, - }); - - return response.data.signals.map(this.transformSignal); - } - - /** - * Get historical signals - */ - async getHistoricalSignals( - symbol: string, - options: { - startTime?: Date; - endTime?: Date; - limit?: number; - signalType?: SignalType; - } = {} - ): Promise { - const response = await this.client.get(`/signals/${symbol}/history`, { - params: { - start_time: options.startTime?.toISOString(), - end_time: options.endTime?.toISOString(), - limit: options.limit || 100, - signal_type: options.signalType, - }, - }); - - return response.data.signals.map(this.transformSignal); - } - - // ========================================================================== - // Predictions - // ========================================================================== - - /** - * Get price prediction for a symbol - */ - async getPrediction( - symbol: string, - horizonMinutes: number = 90 - ): Promise { - const response = await this.client.get(`/predictions/${symbol}`, { - params: { horizon_minutes: horizonMinutes }, - }); - - return { - targetPrice: response.data.target_price, - expectedHigh: response.data.expected_high, - expectedLow: response.data.expected_low, - stopLoss: response.data.stop_loss, - takeProfit: response.data.take_profit, - riskRewardRatio: response.data.risk_reward_ratio, - probabilityUp: response.data.probability_up, - probabilityDown: response.data.probability_down, - volatilityForecast: response.data.volatility_forecast, - }; - } - - /** - * Get AMD phase prediction - */ - async getAMDPhase(symbol: string): Promise<{ - phase: AMDPhase; - confidence: number; - expectedDuration: number; - nextPhase: AMDPhase; - }> { - const response = await this.client.get(`/amd/${symbol}`); - - return { - phase: response.data.phase, - confidence: response.data.confidence, - expectedDuration: response.data.expected_duration, - nextPhase: response.data.next_phase, - }; - } - - // ========================================================================== - // Indicators - // ========================================================================== - - /** - * Get technical indicators for a symbol - */ - async getIndicators(symbol: string): Promise { - const response = await this.client.get(`/indicators/${symbol}`); - - return { - rsi: response.data.rsi, - macd: { - value: response.data.macd.value, - signal: response.data.macd.signal, - histogram: response.data.macd.histogram, - }, - atr: response.data.atr, - atrPercent: response.data.atr_percent, - volumeRatio: response.data.volume_ratio, - ema20: response.data.ema_20, - ema50: response.data.ema_50, - ema200: response.data.ema_200, - bollingerBands: { - upper: response.data.bollinger.upper, - middle: response.data.bollinger.middle, - lower: response.data.bollinger.lower, - width: response.data.bollinger.width, - percentB: response.data.bollinger.percent_b, - }, - supportLevels: response.data.support_levels, - resistanceLevels: response.data.resistance_levels, - }; - } - - // ========================================================================== - // Backtesting - // ========================================================================== - - /** - * Run backtest for a strategy - */ - async runBacktest( - symbol: string, - options: { - startDate: Date; - endDate: Date; - initialCapital?: number; - strategy?: string; - params?: Record; - } - ): Promise { - const response = await this.client.post('/backtest', { - symbol, - start_date: options.startDate.toISOString(), - end_date: options.endDate.toISOString(), - initial_capital: options.initialCapital || 10000, - strategy: options.strategy || 'default', - params: options.params || {}, - }); - - return { - symbol: response.data.symbol, - startDate: new Date(response.data.start_date), - endDate: new Date(response.data.end_date), - initialCapital: response.data.initial_capital, - finalCapital: response.data.final_capital, - totalReturn: response.data.total_return, - annualizedReturn: response.data.annualized_return, - maxDrawdown: response.data.max_drawdown, - sharpeRatio: response.data.sharpe_ratio, - winRate: response.data.win_rate, - totalTrades: response.data.total_trades, - profitFactor: response.data.profit_factor, - trades: response.data.trades.map((t: Record) => ({ - entryTime: new Date(t.entry_time as string), - exitTime: new Date(t.exit_time as string), - side: t.side, - entryPrice: t.entry_price, - exitPrice: t.exit_price, - quantity: t.quantity, - pnl: t.pnl, - pnlPercent: t.pnl_percent, - exitReason: t.exit_reason, - })), - }; - } - - // ========================================================================== - // Model Management - // ========================================================================== - - /** - * Trigger model retraining - */ - async triggerRetraining(symbol?: string): Promise<{ jobId: string }> { - const response = await this.client.post('/models/retrain', { - symbol, - }); - - return { - jobId: response.data.job_id, - }; - } - - /** - * Get retraining job status - */ - async getRetrainingStatus(jobId: string): Promise<{ - status: 'pending' | 'running' | 'completed' | 'failed'; - progress: number; - message: string; - }> { - const response = await this.client.get(`/models/retrain/${jobId}`); - - return { - status: response.data.status, - progress: response.data.progress, - message: response.data.message, - }; - } - - /** - * Get available models - */ - async getModels(): Promise< - { - id: string; - symbol: string; - version: string; - accuracy: number; - createdAt: Date; - isActive: boolean; - }[] - > { - const response = await this.client.get('/models'); - - return response.data.models.map((m: Record) => ({ - id: m.id, - symbol: m.symbol, - version: m.version, - accuracy: m.accuracy, - createdAt: new Date(m.created_at as string), - isActive: m.is_active, - })); - } - - // ========================================================================== - // Private Methods - // ========================================================================== - - private transformSignal(data: Record): MLSignal { - return { - id: data.id as string, - symbol: data.symbol as string, - timestamp: new Date(data.timestamp as string), - signalType: data.signal_type as SignalType, - confidence: data.confidence as number, - timeHorizon: data.time_horizon as TimeHorizon, - amdPhase: data.amd_phase as AMDPhase, - indicators: data.indicators as MLIndicators, - prediction: data.prediction as MLPrediction, - reasoning: data.reasoning as string, - expiresAt: new Date(data.expires_at as string), - }; - } - - private async handleError(error: AxiosError): Promise { - if (!error.response) { - // Network error - this.isConnected = false; - this.emit('connectionStatus', false); - throw new Error('ML Engine is not reachable'); - } - - const status = error.response.status; - const message = (error.response.data as Record)?.detail || error.message; - - if (status === 429) { - throw new Error('Rate limit exceeded on ML Engine'); - } - - if (status === 503) { - this.isConnected = false; - this.emit('connectionStatus', false); - throw new Error('ML Engine is temporarily unavailable'); - } - - throw new Error(`ML Engine error: ${message}`); - } - - /** - * Retry a request with exponential backoff - */ - private async retryRequest( - request: () => Promise, - attempts: number = this.config.retryAttempts - ): Promise { - let lastError: Error | undefined; - - for (let i = 0; i < attempts; i++) { - try { - return await request(); - } catch (error) { - lastError = error as Error; - if (i < attempts - 1) { - await new Promise((resolve) => - setTimeout(resolve, this.config.retryDelay * Math.pow(2, i)) - ); - } - } - } - - throw lastError; - } -} - -// Export singleton instance -export const mlIntegrationService = new MLIntegrationService(); diff --git a/apps/backend/src/modules/ml/services/ml-overlay.service.ts b/apps/backend/src/modules/ml/services/ml-overlay.service.ts deleted file mode 100644 index a2bff55..0000000 --- a/apps/backend/src/modules/ml/services/ml-overlay.service.ts +++ /dev/null @@ -1,517 +0,0 @@ -/** - * ML Overlay Service - * Provides chart overlay data from ML predictions for trading visualization - */ - -import { mlIntegrationService, MLSignal, MLIndicators, AMDPhase } from './ml-integration.service'; - -// ============================================================================ -// Types -// ============================================================================ - -export interface PriceLevel { - price: number; - type: 'support' | 'resistance' | 'target' | 'stop_loss' | 'entry'; - strength: 'weak' | 'moderate' | 'strong'; - label?: string; -} - -export interface TrendLine { - startTime: Date; - endTime: Date; - startPrice: number; - endPrice: number; - type: 'support' | 'resistance' | 'channel_upper' | 'channel_lower'; - confidence: number; -} - -export interface SignalMarker { - time: Date; - price: number; - type: 'buy' | 'sell' | 'hold'; - confidence: number; - label: string; - expired: boolean; -} - -export interface ZoneOverlay { - priceHigh: number; - priceLow: number; - type: 'accumulation' | 'distribution' | 'liquidity' | 'demand' | 'supply'; - startTime: Date; - endTime?: Date; - active: boolean; - label: string; -} - -export interface PredictionBand { - time: Date; - upperBound: number; - lowerBound: number; - expectedPrice: number; - confidence: number; -} - -export interface ChartOverlay { - symbol: string; - timestamp: Date; - priceLevels: PriceLevel[]; - trendLines: TrendLine[]; - signalMarkers: SignalMarker[]; - zones: ZoneOverlay[]; - predictionBands: PredictionBand[]; - indicators: IndicatorOverlay; - amdPhase: AMDPhaseOverlay; -} - -export interface IndicatorOverlay { - ema: { - ema20: number[]; - ema50: number[]; - ema200: number[]; - }; - bollingerBands: { - upper: number[]; - middle: number[]; - lower: number[]; - }; - rsi: number[]; - macd: { - macdLine: number[]; - signalLine: number[]; - histogram: number[]; - }; - volume: { - bars: number[]; - average: number; - ratio: number; - }; -} - -export interface AMDPhaseOverlay { - currentPhase: AMDPhase; - phaseProgress: number; - phaseStartTime: Date; - expectedPhaseDuration: number; - confidence: number; - markers: { - time: Date; - phase: AMDPhase; - label: string; - }[]; -} - -export interface OverlayConfig { - showPriceLevels: boolean; - showTrendLines: boolean; - showSignalMarkers: boolean; - showZones: boolean; - showPredictionBands: boolean; - showIndicators: boolean; - showAMDPhase: boolean; - indicatorConfig: { - ema: boolean; - bollingerBands: boolean; - rsi: boolean; - macd: boolean; - volume: boolean; - }; -} - -const DEFAULT_OVERLAY_CONFIG: OverlayConfig = { - showPriceLevels: true, - showTrendLines: true, - showSignalMarkers: true, - showZones: true, - showPredictionBands: false, - showIndicators: true, - showAMDPhase: true, - indicatorConfig: { - ema: true, - bollingerBands: true, - rsi: true, - macd: true, - volume: true, - }, -}; - -// ============================================================================ -// ML Overlay Service -// ============================================================================ - -class MLOverlayService { - private overlayCache: Map = new Map(); - private readonly CACHE_TTL_MS = 30000; // 30 seconds - - /** - * Get complete chart overlay for a symbol - */ - async getChartOverlay( - symbol: string, - config: Partial = {} - ): Promise { - const mergedConfig = { ...DEFAULT_OVERLAY_CONFIG, ...config }; - - // Check cache - const cached = this.overlayCache.get(symbol); - if (cached && cached.expiresAt > new Date()) { - return cached.overlay; - } - - // Fetch fresh data - const [signal, indicators, amdPhase] = await Promise.all([ - mlIntegrationService.getSignal(symbol), - mlIntegrationService.getIndicators(symbol), - mlIntegrationService.getAMDPhase(symbol), - ]); - - const overlay = this.buildOverlay(symbol, signal, indicators, amdPhase, mergedConfig); - - // Cache the overlay - this.overlayCache.set(symbol, { - overlay, - expiresAt: new Date(Date.now() + this.CACHE_TTL_MS), - }); - - return overlay; - } - - /** - * Get overlays for multiple symbols - */ - async getBatchOverlays( - symbols: string[], - config: Partial = {} - ): Promise> { - const results = new Map(); - - await Promise.all( - symbols.map(async (symbol) => { - try { - const overlay = await this.getChartOverlay(symbol, config); - results.set(symbol, overlay); - } catch (error) { - console.error(`[MLOverlay] Failed to get overlay for ${symbol}:`, error); - } - }) - ); - - return results; - } - - /** - * Get price levels overlay only - */ - async getPriceLevels(symbol: string): Promise { - const signal = await mlIntegrationService.getSignal(symbol); - return this.buildPriceLevels(signal); - } - - /** - * Get signal markers overlay only - */ - async getSignalMarkers(symbol: string, limit: number = 20): Promise { - const signals = await mlIntegrationService.getHistoricalSignals(symbol, { limit }); - return this.buildSignalMarkers(signals); - } - - /** - * Get AMD phase overlay only - */ - async getAMDPhaseOverlay(symbol: string): Promise { - const amdData = await mlIntegrationService.getAMDPhase(symbol); - return this.buildAMDPhaseOverlay(amdData); - } - - /** - * Get prediction bands for a symbol - */ - async getPredictionBands( - symbol: string, - horizonMinutes: number = 90, - intervals: number = 10 - ): Promise { - const prediction = await mlIntegrationService.getPrediction(symbol, horizonMinutes); - return this.buildPredictionBands(prediction, horizonMinutes, intervals); - } - - /** - * Clear cache for a symbol - */ - clearCache(symbol?: string): void { - if (symbol) { - this.overlayCache.delete(symbol); - } else { - this.overlayCache.clear(); - } - } - - // ========================================================================== - // Private Helper Methods - // ========================================================================== - - private buildOverlay( - symbol: string, - signal: MLSignal, - indicators: MLIndicators, - amdData: { phase: AMDPhase; confidence: number }, - config: OverlayConfig - ): ChartOverlay { - const now = new Date(); - - return { - symbol, - timestamp: now, - priceLevels: config.showPriceLevels ? this.buildPriceLevels(signal) : [], - trendLines: config.showTrendLines ? this.buildTrendLines(indicators) : [], - signalMarkers: config.showSignalMarkers ? this.buildCurrentSignalMarker(signal) : [], - zones: config.showZones ? this.buildZones(signal, amdData) : [], - predictionBands: [], // Only populated on explicit request - indicators: config.showIndicators ? this.buildIndicatorOverlay(indicators, config) : this.emptyIndicatorOverlay(), - amdPhase: config.showAMDPhase ? this.buildAMDPhaseOverlay(amdData) : this.emptyAMDPhaseOverlay(), - }; - } - - private buildPriceLevels(signal: MLSignal): PriceLevel[] { - const levels: PriceLevel[] = []; - - // Support levels - signal.indicators.supportLevels.forEach((price, index) => { - levels.push({ - price, - type: 'support', - strength: index === 0 ? 'strong' : index === 1 ? 'moderate' : 'weak', - label: `S${index + 1}`, - }); - }); - - // Resistance levels - signal.indicators.resistanceLevels.forEach((price, index) => { - levels.push({ - price, - type: 'resistance', - strength: index === 0 ? 'strong' : index === 1 ? 'moderate' : 'weak', - label: `R${index + 1}`, - }); - }); - - // Target and stop loss - if (signal.prediction.targetPrice) { - levels.push({ - price: signal.prediction.targetPrice, - type: 'target', - strength: 'strong', - label: 'Target', - }); - } - - if (signal.prediction.stopLoss) { - levels.push({ - price: signal.prediction.stopLoss, - type: 'stop_loss', - strength: 'strong', - label: 'Stop Loss', - }); - } - - if (signal.prediction.takeProfit) { - levels.push({ - price: signal.prediction.takeProfit, - type: 'target', - strength: 'moderate', - label: 'Take Profit', - }); - } - - return levels; - } - - private buildTrendLines(indicators: MLIndicators): TrendLine[] { - const lines: TrendLine[] = []; - const now = new Date(); - const hourAgo = new Date(now.getTime() - 3600000); - - // Bollinger band channels - lines.push({ - startTime: hourAgo, - endTime: now, - startPrice: indicators.bollingerBands.upper, - endPrice: indicators.bollingerBands.upper, - type: 'channel_upper', - confidence: 0.8, - }); - - lines.push({ - startTime: hourAgo, - endTime: now, - startPrice: indicators.bollingerBands.lower, - endPrice: indicators.bollingerBands.lower, - type: 'channel_lower', - confidence: 0.8, - }); - - return lines; - } - - private buildCurrentSignalMarker(signal: MLSignal): SignalMarker[] { - return [ - { - time: signal.timestamp, - price: signal.prediction.targetPrice, - type: signal.signalType, - confidence: signal.confidence, - label: `${signal.signalType.toUpperCase()} (${Math.round(signal.confidence * 100)}%)`, - expired: signal.expiresAt < new Date(), - }, - ]; - } - - private buildSignalMarkers(signals: MLSignal[]): SignalMarker[] { - const now = new Date(); - return signals.map((signal) => ({ - time: signal.timestamp, - price: signal.prediction.targetPrice, - type: signal.signalType, - confidence: signal.confidence, - label: `${signal.signalType.toUpperCase()}`, - expired: signal.expiresAt < now, - })); - } - - private buildZones( - signal: MLSignal, - amdData: { phase: AMDPhase; confidence: number } - ): ZoneOverlay[] { - const zones: ZoneOverlay[] = []; - const now = new Date(); - - // Create zone based on current AMD phase - if (amdData.phase !== 'unknown') { - const zoneType = amdData.phase === 'accumulation' - ? 'demand' - : amdData.phase === 'distribution' - ? 'supply' - : 'liquidity'; - - zones.push({ - priceHigh: signal.indicators.resistanceLevels[0] || signal.prediction.expectedHigh, - priceLow: signal.indicators.supportLevels[0] || signal.prediction.expectedLow, - type: zoneType, - startTime: new Date(now.getTime() - 3600000), - active: true, - label: `${amdData.phase.charAt(0).toUpperCase() + amdData.phase.slice(1)} Zone`, - }); - } - - return zones; - } - - private buildAMDPhaseOverlay(amdData: { phase: AMDPhase; confidence: number; phaseStartTime?: Date }): AMDPhaseOverlay { - const now = new Date(); - const phaseStart = amdData.phaseStartTime || new Date(now.getTime() - 1800000); - - return { - currentPhase: amdData.phase, - phaseProgress: 0.5, // Would be calculated from actual data - phaseStartTime: phaseStart, - expectedPhaseDuration: 3600000, // 1 hour default - confidence: amdData.confidence, - markers: [ - { - time: phaseStart, - phase: amdData.phase, - label: `${amdData.phase.charAt(0).toUpperCase() + amdData.phase.slice(1)} Start`, - }, - ], - }; - } - - private buildPredictionBands( - prediction: { expectedHigh: number; expectedLow: number; targetPrice: number }, - horizonMinutes: number, - intervals: number - ): PredictionBand[] { - const bands: PredictionBand[] = []; - const now = new Date(); - const intervalMs = (horizonMinutes * 60000) / intervals; - - for (let i = 0; i <= intervals; i++) { - const time = new Date(now.getTime() + i * intervalMs); - const progress = i / intervals; - - // Expand uncertainty over time - const expansion = 1 + progress * 0.5; - const range = prediction.expectedHigh - prediction.expectedLow; - - bands.push({ - time, - upperBound: prediction.targetPrice + (range / 2) * expansion, - lowerBound: prediction.targetPrice - (range / 2) * expansion, - expectedPrice: prediction.targetPrice, - confidence: Math.max(0.3, 1 - progress * 0.5), - }); - } - - return bands; - } - - private buildIndicatorOverlay(indicators: MLIndicators, config: OverlayConfig): IndicatorOverlay { - const ic = config.indicatorConfig; - - return { - ema: ic.ema - ? { - ema20: [indicators.ema20], - ema50: [indicators.ema50], - ema200: [indicators.ema200], - } - : { ema20: [], ema50: [], ema200: [] }, - bollingerBands: ic.bollingerBands - ? { - upper: [indicators.bollingerBands.upper], - middle: [indicators.bollingerBands.middle], - lower: [indicators.bollingerBands.lower], - } - : { upper: [], middle: [], lower: [] }, - rsi: ic.rsi ? [indicators.rsi] : [], - macd: ic.macd - ? { - macdLine: [indicators.macd.value], - signalLine: [indicators.macd.signal], - histogram: [indicators.macd.histogram], - } - : { macdLine: [], signalLine: [], histogram: [] }, - volume: ic.volume - ? { - bars: [], - average: 0, - ratio: indicators.volumeRatio, - } - : { bars: [], average: 0, ratio: 0 }, - }; - } - - private emptyIndicatorOverlay(): IndicatorOverlay { - return { - ema: { ema20: [], ema50: [], ema200: [] }, - bollingerBands: { upper: [], middle: [], lower: [] }, - rsi: [], - macd: { macdLine: [], signalLine: [], histogram: [] }, - volume: { bars: [], average: 0, ratio: 0 }, - }; - } - - private emptyAMDPhaseOverlay(): AMDPhaseOverlay { - return { - currentPhase: 'unknown', - phaseProgress: 0, - phaseStartTime: new Date(), - expectedPhaseDuration: 0, - confidence: 0, - markers: [], - }; - } -} - -// Export singleton instance -export const mlOverlayService = new MLOverlayService(); diff --git a/apps/backend/src/modules/payments/controllers/payments.controller.ts b/apps/backend/src/modules/payments/controllers/payments.controller.ts deleted file mode 100644 index b259b10..0000000 --- a/apps/backend/src/modules/payments/controllers/payments.controller.ts +++ /dev/null @@ -1,489 +0,0 @@ -/** - * Payments Controller - * Handles all payment-related endpoints - */ - -import type { Request, Response, NextFunction } from 'express'; -import { stripeService } from '../services/stripe.service'; -import { walletService } from '../services/wallet.service'; -import { subscriptionService } from '../services/subscription.service'; -import { enrollmentService } from '../../education/services/enrollment.service'; -import { logger } from '../../../shared/utils/logger'; -import type { AuthenticatedRequest } from '../../../core/guards/auth.guard'; - -// ============================================================================ -// Plans -// ============================================================================ - -export async function getPlans(req: Request, res: Response, next: NextFunction): Promise { - try { - const plans = await subscriptionService.getPlans(); - res.json({ success: true, data: plans }); - } catch (error) { - next(error); - } -} - -export async function getPlanBySlug(req: Request, res: Response, next: NextFunction): Promise { - try { - const { slug } = req.params; - const plan = await subscriptionService.getPlanBySlug(slug); - - if (!plan) { - res.status(404).json({ success: false, error: 'Plan not found' }); - return; - } - - res.json({ success: true, data: plan }); - } catch (error) { - next(error); - } -} - -// ============================================================================ -// Subscriptions -// ============================================================================ - -export async function getMySubscription(req: Request, res: Response, next: NextFunction): Promise { - try { - const authReq = req as AuthenticatedRequest; - const subscription = await subscriptionService.getSubscriptionByUserId(authReq.user.id); - res.json({ success: true, data: subscription }); - } catch (error) { - next(error); - } -} - -export async function getSubscriptionHistory(req: Request, res: Response, next: NextFunction): Promise { - try { - const authReq = req as AuthenticatedRequest; - const subscriptions = await subscriptionService.getSubscriptionHistory(authReq.user.id); - res.json({ success: true, data: subscriptions }); - } catch (error) { - next(error); - } -} - -export async function cancelSubscription(req: Request, res: Response, next: NextFunction): Promise { - try { - const authReq = req as AuthenticatedRequest; - const { immediately, reason } = req.body; - - const subscription = await subscriptionService.cancelSubscription( - authReq.user.id, - immediately === true, - reason - ); - - res.json({ success: true, data: subscription }); - } catch (error) { - next(error); - } -} - -export async function resumeSubscription(req: Request, res: Response, next: NextFunction): Promise { - try { - const authReq = req as AuthenticatedRequest; - const subscription = await subscriptionService.resumeSubscription(authReq.user.id); - res.json({ success: true, data: subscription }); - } catch (error) { - next(error); - } -} - -export async function changePlan(req: Request, res: Response, next: NextFunction): Promise { - try { - const authReq = req as AuthenticatedRequest; - const { planId, billingCycle } = req.body; - - if (!planId) { - res.status(400).json({ success: false, error: 'Plan ID is required' }); - return; - } - - const subscription = await subscriptionService.changePlan( - authReq.user.id, - planId, - billingCycle - ); - - res.json({ success: true, data: subscription }); - } catch (error) { - next(error); - } -} - -export async function getMyPlanFeatures(req: Request, res: Response, next: NextFunction): Promise { - try { - const authReq = req as AuthenticatedRequest; - const features = await subscriptionService.getUserPlanFeatures(authReq.user.id); - res.json({ success: true, data: features }); - } catch (error) { - next(error); - } -} - -// ============================================================================ -// Checkout & Billing Portal -// ============================================================================ - -export async function createCheckoutSession(req: Request, res: Response, next: NextFunction): Promise { - try { - const authReq = req as AuthenticatedRequest; - const { planId, courseId, billingCycle, successUrl, cancelUrl, promoCode } = req.body; - - if (!successUrl || !cancelUrl) { - res.status(400).json({ success: false, error: 'Success and cancel URLs are required' }); - return; - } - - if (!planId && !courseId) { - res.status(400).json({ success: false, error: 'Plan ID or course ID is required' }); - return; - } - - const session = await stripeService.createCheckoutSession({ - userId: authReq.user.id, - planId, - courseId, - billingCycle, - successUrl, - cancelUrl, - promoCode, - }); - - res.json({ success: true, data: session }); - } catch (error) { - next(error); - } -} - -export async function createBillingPortalSession(req: Request, res: Response, next: NextFunction): Promise { - try { - const authReq = req as AuthenticatedRequest; - const { returnUrl } = req.body; - - if (!returnUrl) { - res.status(400).json({ success: false, error: 'Return URL is required' }); - return; - } - - const session = await stripeService.createBillingPortalSession(authReq.user.id, returnUrl); - res.json({ success: true, data: session }); - } catch (error) { - next(error); - } -} - -// ============================================================================ -// Payment Methods -// ============================================================================ - -export async function getPaymentMethods(req: Request, res: Response, next: NextFunction): Promise { - try { - const authReq = req as AuthenticatedRequest; - const methods = await stripeService.listPaymentMethods(authReq.user.id); - res.json({ success: true, data: methods }); - } catch (error) { - next(error); - } -} - -export async function attachPaymentMethod(req: Request, res: Response, next: NextFunction): Promise { - try { - const authReq = req as AuthenticatedRequest; - const { paymentMethodId } = req.body; - - if (!paymentMethodId) { - res.status(400).json({ success: false, error: 'Payment method ID is required' }); - return; - } - - const method = await stripeService.attachPaymentMethod(authReq.user.id, paymentMethodId); - res.json({ success: true, data: method }); - } catch (error) { - next(error); - } -} - -export async function detachPaymentMethod(req: Request, res: Response, next: NextFunction): Promise { - try { - const { paymentMethodId } = req.params; - await stripeService.detachPaymentMethod(paymentMethodId); - res.json({ success: true, message: 'Payment method detached' }); - } catch (error) { - next(error); - } -} - -export async function setDefaultPaymentMethod(req: Request, res: Response, next: NextFunction): Promise { - try { - const authReq = req as AuthenticatedRequest; - const { paymentMethodId } = req.body; - - if (!paymentMethodId) { - res.status(400).json({ success: false, error: 'Payment method ID is required' }); - return; - } - - await stripeService.updateDefaultPaymentMethod(authReq.user.id, paymentMethodId); - res.json({ success: true, message: 'Default payment method updated' }); - } catch (error) { - next(error); - } -} - -// ============================================================================ -// Wallet -// ============================================================================ - -export async function getMyWallet(req: Request, res: Response, next: NextFunction): Promise { - try { - const authReq = req as AuthenticatedRequest; - const currency = (req.query.currency as string) || 'USD'; - const wallet = await walletService.getOrCreateWallet(authReq.user.id, currency); - res.json({ success: true, data: wallet }); - } catch (error) { - next(error); - } -} - -export async function getMyWallets(req: Request, res: Response, next: NextFunction): Promise { - try { - const authReq = req as AuthenticatedRequest; - const wallets = await walletService.getUserWallets(authReq.user.id); - res.json({ success: true, data: wallets }); - } catch (error) { - next(error); - } -} - -export async function getWalletTransactions(req: Request, res: Response, next: NextFunction): Promise { - try { - const authReq = req as AuthenticatedRequest; - const { - walletId, - transactionType, - status, - startDate, - endDate, - page = '1', - pageSize = '20', - } = req.query; - - const limit = Math.min(parseInt(pageSize as string, 10) || 20, 100); - const offset = (parseInt(page as string, 10) - 1) * limit; - - const result = await walletService.getTransactions(authReq.user.id, { - walletId: walletId as string | undefined, - transactionType: transactionType as 'deposit' | 'withdrawal' | 'fee' | 'refund' | undefined, - status: status as 'pending' | 'processing' | 'failed' | 'cancelled' | undefined, - startDate: startDate ? new Date(startDate as string) : undefined, - endDate: endDate ? new Date(endDate as string) : undefined, - limit, - offset, - }); - - res.json({ - success: true, - data: result.transactions, - meta: { - total: result.total, - page: parseInt(page as string, 10), - pageSize: limit, - totalPages: Math.ceil(result.total / limit), - }, - }); - } catch (error) { - next(error); - } -} - -export async function getWalletStats(req: Request, res: Response, next: NextFunction): Promise { - try { - const authReq = req as AuthenticatedRequest; - const currency = (req.query.currency as string) || 'USD'; - const stats = await walletService.getWalletStats(authReq.user.id, currency); - res.json({ success: true, data: stats }); - } catch (error) { - next(error); - } -} - -export async function createDeposit(req: Request, res: Response, next: NextFunction): Promise { - try { - const authReq = req as AuthenticatedRequest; - const { amount, currency, description } = req.body; - - if (!amount || amount <= 0) { - res.status(400).json({ success: false, error: 'Valid amount is required' }); - return; - } - - const transaction = await walletService.createDeposit({ - userId: authReq.user.id, - amount, - currency, - description, - }); - - res.json({ success: true, data: transaction }); - } catch (error) { - next(error); - } -} - -export async function createWithdrawal(req: Request, res: Response, next: NextFunction): Promise { - try { - const authReq = req as AuthenticatedRequest; - const { amount, currency, payoutMethod, destinationDetails } = req.body; - - if (!amount || amount <= 0) { - res.status(400).json({ success: false, error: 'Valid amount is required' }); - return; - } - - if (!payoutMethod || !destinationDetails) { - res.status(400).json({ success: false, error: 'Payout method and destination details are required' }); - return; - } - - const transaction = await walletService.createWithdrawal({ - userId: authReq.user.id, - amount, - currency, - payoutMethod, - destinationDetails, - }); - - res.json({ success: true, data: transaction }); - } catch (error) { - next(error); - } -} - -// ============================================================================ -// Invoices -// ============================================================================ - -export async function getInvoices(req: Request, res: Response, next: NextFunction): Promise { - try { - const authReq = req as AuthenticatedRequest; - const limit = parseInt(req.query.limit as string, 10) || 10; - - const customer = await stripeService.getCustomerByUserId(authReq.user.id); - if (!customer) { - res.json({ success: true, data: [] }); - return; - } - - const invoices = await stripeService.listInvoices(customer.stripeCustomerId, limit); - res.json({ success: true, data: invoices }); - } catch (error) { - next(error); - } -} - -// ============================================================================ -// Webhooks -// ============================================================================ - -export async function handleStripeWebhook(req: Request, res: Response, next: NextFunction): Promise { - try { - const signature = req.headers['stripe-signature'] as string; - if (!signature) { - res.status(400).json({ success: false, error: 'Missing signature' }); - return; - } - - const event = stripeService.constructWebhookEvent(req.body, signature); - - logger.info('[WebhookController] Stripe event received:', { type: event.type }); - - switch (event.type) { - case 'checkout.session.completed': { - const session = event.data.object as unknown as Record; - await handleCheckoutComplete(session); - break; - } - - case 'customer.subscription.created': - case 'customer.subscription.updated': { - const subscription = event.data.object as unknown as Record; - await handleSubscriptionUpdate(subscription); - break; - } - - case 'customer.subscription.deleted': { - const subscription = event.data.object as unknown as Record; - await subscriptionService.updateSubscriptionFromStripe(subscription.id as string, { - status: 'cancelled', - cancelledAt: new Date(), - }); - break; - } - - case 'invoice.paid': { - const invoice = event.data.object as unknown as Record; - logger.info('[WebhookController] Invoice paid:', { invoiceId: invoice.id }); - break; - } - - case 'invoice.payment_failed': { - const invoice = event.data.object as unknown as Record; - logger.warn('[WebhookController] Invoice payment failed:', { invoiceId: invoice.id }); - // Could trigger email notification here - break; - } - - default: - logger.debug('[WebhookController] Unhandled event type:', { type: event.type }); - } - - res.json({ received: true }); - } catch (error) { - logger.error('[WebhookController] Webhook error:', error); - next(error); - } -} - -async function handleCheckoutComplete(session: Record): Promise { - const metadata = session.metadata as Record | undefined; - const { userId, planId, courseId, billingCycle } = metadata || {}; - - if (planId) { - // Create subscription - await subscriptionService.createSubscription({ - userId: userId as string, - planId: planId as string, - billingCycle: ((billingCycle as string) || 'monthly') as 'monthly' | 'yearly', - }); - } - - if (courseId) { - // Create course enrollment - await enrollmentService.createEnrollment({ - userId: userId as string, - courseId: courseId as string, - paymentId: session.payment_intent as string, - }); - } -} - -async function handleSubscriptionUpdate(subscription: Record): Promise { - const statusMap: Record = { - 'active': 'active', - 'trialing': 'trialing', - 'past_due': 'past_due', - 'canceled': 'cancelled', - 'unpaid': 'unpaid', - 'paused': 'paused', - }; - - await subscriptionService.updateSubscriptionFromStripe(subscription.id as string, { - status: (statusMap[subscription.status as string] || 'active') as 'active' | 'trialing' | 'past_due' | 'cancelled' | 'unpaid' | 'paused', - currentPeriodStart: new Date((subscription.current_period_start as number) * 1000), - currentPeriodEnd: new Date((subscription.current_period_end as number) * 1000), - cancelAtPeriodEnd: subscription.cancel_at_period_end as boolean, - }); -} diff --git a/apps/backend/src/modules/payments/payments.routes.ts b/apps/backend/src/modules/payments/payments.routes.ts deleted file mode 100644 index 549e00a..0000000 --- a/apps/backend/src/modules/payments/payments.routes.ts +++ /dev/null @@ -1,189 +0,0 @@ -/** - * Payments Routes - * Subscription, billing, wallet, and payment management - */ - -import { Router, RequestHandler, raw } from 'express'; -import * as paymentsController from './controllers/payments.controller'; -import { requireAuth } from '../../core/guards/auth.guard'; - -const router = Router(); - -// Type cast helper for authenticated routes -// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -const authHandler = (fn: Function): RequestHandler => fn as RequestHandler; - -// ============================================================================ -// Public Routes -// ============================================================================ - -/** - * GET /api/v1/payments/plans - * Get all available subscription plans - */ -router.get('/plans', paymentsController.getPlans); - -/** - * GET /api/v1/payments/plans/:slug - * Get plan details by slug - */ -router.get('/plans/:slug', paymentsController.getPlanBySlug); - -// ============================================================================ -// Subscription Routes (Authenticated) -// ============================================================================ - -/** - * GET /api/v1/payments/subscription - * Get current user's subscription with plan details - */ -router.get('/subscription', authHandler(requireAuth), authHandler(paymentsController.getMySubscription)); - -/** - * GET /api/v1/payments/subscription/history - * Get user's subscription history - */ -router.get('/subscription/history', authHandler(requireAuth), authHandler(paymentsController.getSubscriptionHistory)); - -/** - * GET /api/v1/payments/subscription/features - * Get user's current plan features - */ -router.get('/subscription/features', authHandler(requireAuth), authHandler(paymentsController.getMyPlanFeatures)); - -/** - * POST /api/v1/payments/subscription/cancel - * Cancel current subscription - * Body: { immediately?: boolean, reason?: string } - */ -router.post('/subscription/cancel', authHandler(requireAuth), authHandler(paymentsController.cancelSubscription)); - -/** - * POST /api/v1/payments/subscription/resume - * Resume a cancelled subscription (before period end) - */ -router.post('/subscription/resume', authHandler(requireAuth), authHandler(paymentsController.resumeSubscription)); - -/** - * POST /api/v1/payments/subscription/change-plan - * Change subscription plan - * Body: { planId: string, billingCycle?: 'monthly' | 'yearly' } - */ -router.post('/subscription/change-plan', authHandler(requireAuth), authHandler(paymentsController.changePlan)); - -// ============================================================================ -// Checkout & Billing Portal Routes -// ============================================================================ - -/** - * POST /api/v1/payments/checkout - * Create a Stripe checkout session - * Body: { planId?: string, courseId?: string, billingCycle?: string, successUrl: string, cancelUrl: string, promoCode?: string } - */ -router.post('/checkout', authHandler(requireAuth), authHandler(paymentsController.createCheckoutSession)); - -/** - * POST /api/v1/payments/billing-portal - * Create a Stripe billing portal session - * Body: { returnUrl: string } - */ -router.post('/billing-portal', authHandler(requireAuth), authHandler(paymentsController.createBillingPortalSession)); - -// ============================================================================ -// Payment Methods Routes -// ============================================================================ - -/** - * GET /api/v1/payments/methods - * Get user's saved payment methods - */ -router.get('/methods', authHandler(requireAuth), authHandler(paymentsController.getPaymentMethods)); - -/** - * POST /api/v1/payments/methods - * Attach a payment method to user - * Body: { paymentMethodId: string } - */ -router.post('/methods', authHandler(requireAuth), authHandler(paymentsController.attachPaymentMethod)); - -/** - * DELETE /api/v1/payments/methods/:paymentMethodId - * Detach a payment method - */ -router.delete('/methods/:paymentMethodId', authHandler(requireAuth), authHandler(paymentsController.detachPaymentMethod)); - -/** - * POST /api/v1/payments/methods/default - * Set default payment method - * Body: { paymentMethodId: string } - */ -router.post('/methods/default', authHandler(requireAuth), authHandler(paymentsController.setDefaultPaymentMethod)); - -// ============================================================================ -// Wallet Routes -// ============================================================================ - -/** - * GET /api/v1/payments/wallet - * Get user's wallet (creates if doesn't exist) - * Query: { currency?: string } - */ -router.get('/wallet', authHandler(requireAuth), authHandler(paymentsController.getMyWallet)); - -/** - * GET /api/v1/payments/wallets - * Get all user's wallets - */ -router.get('/wallets', authHandler(requireAuth), authHandler(paymentsController.getMyWallets)); - -/** - * GET /api/v1/payments/wallet/transactions - * Get wallet transactions - * Query: { walletId?, transactionType?, status?, startDate?, endDate?, page?, pageSize? } - */ -router.get('/wallet/transactions', authHandler(requireAuth), authHandler(paymentsController.getWalletTransactions)); - -/** - * GET /api/v1/payments/wallet/stats - * Get wallet statistics - * Query: { currency?: string } - */ -router.get('/wallet/stats', authHandler(requireAuth), authHandler(paymentsController.getWalletStats)); - -/** - * POST /api/v1/payments/wallet/deposit - * Create a deposit request - * Body: { amount: number, currency?: string, description?: string } - */ -router.post('/wallet/deposit', authHandler(requireAuth), authHandler(paymentsController.createDeposit)); - -/** - * POST /api/v1/payments/wallet/withdraw - * Create a withdrawal request - * Body: { amount: number, currency?: string, payoutMethod: string, destinationDetails: object } - */ -router.post('/wallet/withdraw', authHandler(requireAuth), authHandler(paymentsController.createWithdrawal)); - -// ============================================================================ -// Invoice Routes -// ============================================================================ - -/** - * GET /api/v1/payments/invoices - * Get user's invoices from Stripe - * Query: { limit?: number } - */ -router.get('/invoices', authHandler(requireAuth), authHandler(paymentsController.getInvoices)); - -// ============================================================================ -// Webhook Route (No Auth - Verified by Stripe Signature) -// ============================================================================ - -/** - * POST /api/v1/payments/webhook - * Stripe webhook endpoint - * Note: Must use raw body parser for signature verification - */ -router.post('/webhook', raw({ type: 'application/json' }), paymentsController.handleStripeWebhook); - -export { router as paymentsRouter }; diff --git a/apps/backend/src/modules/payments/services/stripe.service.ts b/apps/backend/src/modules/payments/services/stripe.service.ts deleted file mode 100644 index e367eaa..0000000 --- a/apps/backend/src/modules/payments/services/stripe.service.ts +++ /dev/null @@ -1,437 +0,0 @@ -/** - * Stripe Service - * Handles Stripe API integration - */ - -import Stripe from 'stripe'; -import { db } from '../../../shared/database'; -import { logger } from '../../../shared/utils/logger'; -import { config } from '../../../config'; -import type { - StripeCustomer, - CheckoutSession, - CreateCheckoutSessionInput, - BillingPortalSession, - SubscriptionPlan, -} from '../types/payments.types'; - -// ============================================================================ -// Stripe Client -// ============================================================================ - -const stripe = new Stripe(config.stripe?.secretKey || process.env.STRIPE_SECRET_KEY || '', { - apiVersion: '2025-02-24.acacia', -}); - -// ============================================================================ -// Helper Functions -// ============================================================================ - -function transformPlan(row: Record): SubscriptionPlan { - return { - id: row.id as string, - name: row.name as string, - slug: row.slug as string, - description: row.description as string | undefined, - priceMonthly: parseFloat(row.price_monthly as string), - priceYearly: row.price_yearly ? parseFloat(row.price_yearly as string) : undefined, - currency: row.currency as string, - stripePriceIdMonthly: row.stripe_price_id_monthly as string | undefined, - stripePriceIdYearly: row.stripe_price_id_yearly as string | undefined, - stripeProductId: row.stripe_product_id as string | undefined, - features: (row.features as SubscriptionPlan['features']) || [], - maxWatchlists: row.max_watchlists as number | undefined, - maxAlerts: row.max_alerts as number | undefined, - mlPredictionsAccess: row.ml_predictions_access as boolean, - signalsAccess: row.signals_access as boolean, - backtestingAccess: row.backtesting_access as boolean, - apiAccess: row.api_access as boolean, - prioritySupport: row.priority_support as boolean, - coursesAccess: row.courses_access as SubscriptionPlan['coursesAccess'], - sortOrder: row.sort_order as number, - isFeatured: row.is_featured as boolean, - isActive: row.is_active as boolean, - createdAt: new Date(row.created_at as string), - updatedAt: new Date(row.updated_at as string), - }; -} - -// ============================================================================ -// Stripe Service Class -// ============================================================================ - -class StripeService { - // ========================================================================== - // Customer Management - // ========================================================================== - - async getOrCreateCustomer(userId: string, email: string): Promise { - // Check if customer exists - const existing = await db.query>( - `SELECT * FROM financial.stripe_customers WHERE user_id = $1`, - [userId] - ); - - if (existing.rows.length > 0) { - const row = existing.rows[0]; - return { - id: row.id as string, - userId: row.user_id as string, - stripeCustomerId: row.stripe_customer_id as string, - email: row.email as string | undefined, - defaultPaymentMethodId: row.default_payment_method_id as string | undefined, - metadata: row.metadata as Record | undefined, - createdAt: new Date(row.created_at as string), - updatedAt: new Date(row.updated_at as string), - }; - } - - // Create Stripe customer - const stripeCustomer = await stripe.customers.create({ - email, - metadata: { userId }, - }); - - // Save to database - const result = await db.query>( - `INSERT INTO financial.stripe_customers (user_id, stripe_customer_id, email) - VALUES ($1, $2, $3) - RETURNING *`, - [userId, stripeCustomer.id, email] - ); - - logger.info('[StripeService] Customer created:', { userId, stripeCustomerId: stripeCustomer.id }); - - const row = result.rows[0]; - return { - id: row.id as string, - userId: row.user_id as string, - stripeCustomerId: row.stripe_customer_id as string, - email: row.email as string | undefined, - defaultPaymentMethodId: row.default_payment_method_id as string | undefined, - metadata: row.metadata as Record | undefined, - createdAt: new Date(row.created_at as string), - updatedAt: new Date(row.updated_at as string), - }; - } - - async getCustomerByUserId(userId: string): Promise { - const result = await db.query>( - `SELECT * FROM financial.stripe_customers WHERE user_id = $1`, - [userId] - ); - - if (result.rows.length === 0) return null; - - const row = result.rows[0]; - return { - id: row.id as string, - userId: row.user_id as string, - stripeCustomerId: row.stripe_customer_id as string, - email: row.email as string | undefined, - defaultPaymentMethodId: row.default_payment_method_id as string | undefined, - metadata: row.metadata as Record | undefined, - createdAt: new Date(row.created_at as string), - updatedAt: new Date(row.updated_at as string), - }; - } - - async updateDefaultPaymentMethod(userId: string, paymentMethodId: string): Promise { - const customer = await this.getCustomerByUserId(userId); - if (!customer) throw new Error('Customer not found'); - - // Update in Stripe - await stripe.customers.update(customer.stripeCustomerId, { - invoice_settings: { default_payment_method: paymentMethodId }, - }); - - // Update in database - await db.query( - `UPDATE financial.stripe_customers SET default_payment_method_id = $1 WHERE user_id = $2`, - [paymentMethodId, userId] - ); - } - - // ========================================================================== - // Payment Methods - // ========================================================================== - - async listPaymentMethods(userId: string): Promise { - const customer = await this.getCustomerByUserId(userId); - if (!customer) return []; - - const paymentMethods = await stripe.paymentMethods.list({ - customer: customer.stripeCustomerId, - type: 'card', - }); - - return paymentMethods.data; - } - - async attachPaymentMethod(userId: string, paymentMethodId: string): Promise { - const customer = await this.getOrCreateCustomer(userId, ''); - - const paymentMethod = await stripe.paymentMethods.attach(paymentMethodId, { - customer: customer.stripeCustomerId, - }); - - return paymentMethod; - } - - async detachPaymentMethod(paymentMethodId: string): Promise { - await stripe.paymentMethods.detach(paymentMethodId); - } - - // ========================================================================== - // Checkout Sessions - // ========================================================================== - - async createCheckoutSession(input: CreateCheckoutSessionInput): Promise { - // Get user email - const userResult = await db.query<{ email: string }>( - `SELECT email FROM public.users WHERE id = $1`, - [input.userId] - ); - - if (userResult.rows.length === 0) { - throw new Error('User not found'); - } - - const email = userResult.rows[0].email; - const customer = await this.getOrCreateCustomer(input.userId, email); - - const sessionConfig: Stripe.Checkout.SessionCreateParams = { - customer: customer.stripeCustomerId, - success_url: input.successUrl, - cancel_url: input.cancelUrl, - mode: 'subscription', - line_items: [], - metadata: { userId: input.userId }, - }; - - // Handle subscription checkout - if (input.planId) { - const plan = await this.getPlanById(input.planId); - if (!plan) throw new Error('Plan not found'); - - const priceId = input.billingCycle === 'yearly' - ? plan.stripePriceIdYearly - : plan.stripePriceIdMonthly; - - if (!priceId) throw new Error('Stripe price not configured for this plan'); - - sessionConfig.line_items = [{ price: priceId, quantity: 1 }]; - sessionConfig.metadata!.planId = input.planId; - sessionConfig.metadata!.billingCycle = input.billingCycle || 'monthly'; - } - - // Handle course purchase - if (input.courseId) { - sessionConfig.mode = 'payment'; - const courseResult = await db.query<{ price: string; title: string }>( - `SELECT price, title FROM education.courses WHERE id = $1`, - [input.courseId] - ); - - if (courseResult.rows.length === 0) throw new Error('Course not found'); - - const course = courseResult.rows[0]; - sessionConfig.line_items = [{ - price_data: { - currency: 'usd', - product_data: { name: course.title }, - unit_amount: Math.round(parseFloat(course.price) * 100), - }, - quantity: 1, - }]; - sessionConfig.metadata!.courseId = input.courseId; - } - - // Apply promo code - if (input.promoCode) { - const promo = await this.getStripeCouponByCode(input.promoCode); - if (promo) { - sessionConfig.discounts = [{ coupon: promo }]; - } - } - - const session = await stripe.checkout.sessions.create(sessionConfig); - - logger.info('[StripeService] Checkout session created:', { - sessionId: session.id, - userId: input.userId - }); - - return { - sessionId: session.id, - url: session.url!, - expiresAt: new Date(session.expires_at * 1000), - }; - } - - async getStripeCouponByCode(code: string): Promise { - try { - const promotionCodes = await stripe.promotionCodes.list({ code, limit: 1 }); - if (promotionCodes.data.length > 0 && promotionCodes.data[0].active) { - return promotionCodes.data[0].coupon.id; - } - return null; - } catch { - return null; - } - } - - // ========================================================================== - // Billing Portal - // ========================================================================== - - async createBillingPortalSession(userId: string, returnUrl: string): Promise { - const customer = await this.getCustomerByUserId(userId); - if (!customer) throw new Error('Customer not found'); - - const session = await stripe.billingPortal.sessions.create({ - customer: customer.stripeCustomerId, - return_url: returnUrl, - }); - - return { - url: session.url, - returnUrl, - }; - } - - // ========================================================================== - // Plans - // ========================================================================== - - async getPlans(): Promise { - const result = await db.query>( - `SELECT * FROM financial.subscription_plans WHERE is_active = true ORDER BY sort_order` - ); - return result.rows.map(transformPlan); - } - - async getPlanById(id: string): Promise { - const result = await db.query>( - `SELECT * FROM financial.subscription_plans WHERE id = $1`, - [id] - ); - if (result.rows.length === 0) return null; - return transformPlan(result.rows[0]); - } - - async getPlanBySlug(slug: string): Promise { - const result = await db.query>( - `SELECT * FROM financial.subscription_plans WHERE slug = $1`, - [slug] - ); - if (result.rows.length === 0) return null; - return transformPlan(result.rows[0]); - } - - // ========================================================================== - // Payment Intents - // ========================================================================== - - async createPaymentIntent( - userId: string, - amount: number, - currency: string = 'usd', - metadata: Record = {} - ): Promise { - const userResult = await db.query<{ email: string }>( - `SELECT email FROM public.users WHERE id = $1`, - [userId] - ); - - if (userResult.rows.length === 0) throw new Error('User not found'); - - const customer = await this.getOrCreateCustomer(userId, userResult.rows[0].email); - - const paymentIntent = await stripe.paymentIntents.create({ - amount: Math.round(amount * 100), - currency, - customer: customer.stripeCustomerId, - metadata: { userId, ...metadata }, - }); - - return paymentIntent; - } - - async confirmPaymentIntent(paymentIntentId: string, paymentMethodId: string): Promise { - return stripe.paymentIntents.confirm(paymentIntentId, { - payment_method: paymentMethodId, - }); - } - - // ========================================================================== - // Subscriptions - // ========================================================================== - - async cancelSubscription(stripeSubscriptionId: string, immediately: boolean = false): Promise { - if (immediately) { - return stripe.subscriptions.cancel(stripeSubscriptionId); - } else { - return stripe.subscriptions.update(stripeSubscriptionId, { - cancel_at_period_end: true, - }); - } - } - - async resumeSubscription(stripeSubscriptionId: string): Promise { - return stripe.subscriptions.update(stripeSubscriptionId, { - cancel_at_period_end: false, - }); - } - - async updateSubscriptionPlan(stripeSubscriptionId: string, newPriceId: string): Promise { - const subscription = await stripe.subscriptions.retrieve(stripeSubscriptionId); - - return stripe.subscriptions.update(stripeSubscriptionId, { - items: [{ - id: subscription.items.data[0].id, - price: newPriceId, - }], - proration_behavior: 'create_prorations', - }); - } - - // ========================================================================== - // Refunds - // ========================================================================== - - async createRefund(chargeId: string, amount?: number, reason?: string): Promise { - return stripe.refunds.create({ - charge: chargeId, - amount: amount ? Math.round(amount * 100) : undefined, - reason: reason as Stripe.RefundCreateParams.Reason, - }); - } - - // ========================================================================== - // Webhooks - // ========================================================================== - - constructWebhookEvent(payload: string | Buffer, signature: string): Stripe.Event { - const webhookSecret = config.stripe?.webhookSecret || process.env.STRIPE_WEBHOOK_SECRET || ''; - return stripe.webhooks.constructEvent(payload, signature, webhookSecret); - } - - // ========================================================================== - // Invoices - // ========================================================================== - - async getInvoice(invoiceId: string): Promise { - return stripe.invoices.retrieve(invoiceId); - } - - async listInvoices(customerId: string, limit: number = 10): Promise { - const invoices = await stripe.invoices.list({ - customer: customerId, - limit, - }); - return invoices.data; - } -} - -export const stripeService = new StripeService(); diff --git a/apps/backend/src/modules/payments/services/subscription.service.ts b/apps/backend/src/modules/payments/services/subscription.service.ts deleted file mode 100644 index 5d52096..0000000 --- a/apps/backend/src/modules/payments/services/subscription.service.ts +++ /dev/null @@ -1,514 +0,0 @@ -/** - * Subscription Service - * Handles subscription management and billing cycles - */ - -import { db } from '../../../shared/database'; -import { logger } from '../../../shared/utils/logger'; -import { stripeService } from './stripe.service'; -import type { - Subscription, - SubscriptionWithPlan, - CreateSubscriptionInput, - SubscriptionStatus, - SubscriptionPlan, - BillingCycle, -} from '../types/payments.types'; - -// ============================================================================ -// Helper Functions -// ============================================================================ - -function transformSubscription(row: Record): Subscription { - return { - id: row.id as string, - userId: row.user_id as string, - planId: row.plan_id as string, - stripeSubscriptionId: row.stripe_subscription_id as string | undefined, - stripeCustomerId: row.stripe_customer_id as string | undefined, - status: row.status as SubscriptionStatus, - billingCycle: row.billing_cycle as BillingCycle, - currentPeriodStart: row.current_period_start ? new Date(row.current_period_start as string) : undefined, - currentPeriodEnd: row.current_period_end ? new Date(row.current_period_end as string) : undefined, - trialStart: row.trial_start ? new Date(row.trial_start as string) : undefined, - trialEnd: row.trial_end ? new Date(row.trial_end as string) : undefined, - cancelAtPeriodEnd: row.cancel_at_period_end as boolean, - cancelledAt: row.cancelled_at ? new Date(row.cancelled_at as string) : undefined, - cancellationReason: row.cancellation_reason as string | undefined, - currentPrice: row.current_price ? parseFloat(row.current_price as string) : undefined, - currency: row.currency as string, - createdAt: new Date(row.created_at as string), - updatedAt: new Date(row.updated_at as string), - }; -} - -function transformPlan(row: Record): SubscriptionPlan { - return { - id: row.id as string, - name: row.name as string, - slug: row.slug as string, - description: row.description as string | undefined, - priceMonthly: parseFloat(row.price_monthly as string), - priceYearly: row.price_yearly ? parseFloat(row.price_yearly as string) : undefined, - currency: row.currency as string, - stripePriceIdMonthly: row.stripe_price_id_monthly as string | undefined, - stripePriceIdYearly: row.stripe_price_id_yearly as string | undefined, - stripeProductId: row.stripe_product_id as string | undefined, - features: (row.features as SubscriptionPlan['features']) || [], - maxWatchlists: row.max_watchlists as number | undefined, - maxAlerts: row.max_alerts as number | undefined, - mlPredictionsAccess: row.ml_predictions_access as boolean, - signalsAccess: row.signals_access as boolean, - backtestingAccess: row.backtesting_access as boolean, - apiAccess: row.api_access as boolean, - prioritySupport: row.priority_support as boolean, - coursesAccess: row.courses_access as SubscriptionPlan['coursesAccess'], - sortOrder: row.sort_order as number, - isFeatured: row.is_featured as boolean, - isActive: row.is_active as boolean, - createdAt: new Date(row.created_at as string), - updatedAt: new Date(row.updated_at as string), - }; -} - -// ============================================================================ -// Subscription Service Class -// ============================================================================ - -class SubscriptionService { - // ========================================================================== - // Subscription Queries - // ========================================================================== - - async getSubscriptionById(id: string): Promise { - const result = await db.query>( - `SELECT * FROM financial.subscriptions WHERE id = $1`, - [id] - ); - if (result.rows.length === 0) return null; - return transformSubscription(result.rows[0]); - } - - async getSubscriptionByUserId(userId: string): Promise { - const result = await db.query>( - `SELECT s.*, p.name as plan_name, p.slug as plan_slug, p.description as plan_description, - p.price_monthly, p.price_yearly, p.features, p.max_watchlists, p.max_alerts, - p.ml_predictions_access, p.signals_access, p.backtesting_access, p.api_access, - p.priority_support, p.courses_access, p.sort_order, p.is_featured, - p.stripe_price_id_monthly, p.stripe_price_id_yearly, p.stripe_product_id - FROM financial.subscriptions s - JOIN financial.subscription_plans p ON s.plan_id = p.id - WHERE s.user_id = $1 AND s.status IN ('active', 'trialing', 'past_due') - ORDER BY s.created_at DESC - LIMIT 1`, - [userId] - ); - - if (result.rows.length === 0) return null; - - const row = result.rows[0]; - const subscription = transformSubscription(row); - const plan: SubscriptionPlan = { - id: row.plan_id as string, - name: row.plan_name as string, - slug: row.plan_slug as string, - description: row.plan_description as string | undefined, - priceMonthly: parseFloat(row.price_monthly as string), - priceYearly: row.price_yearly ? parseFloat(row.price_yearly as string) : undefined, - currency: subscription.currency, - stripePriceIdMonthly: row.stripe_price_id_monthly as string | undefined, - stripePriceIdYearly: row.stripe_price_id_yearly as string | undefined, - stripeProductId: row.stripe_product_id as string | undefined, - features: (row.features as SubscriptionPlan['features']) || [], - maxWatchlists: row.max_watchlists as number | undefined, - maxAlerts: row.max_alerts as number | undefined, - mlPredictionsAccess: row.ml_predictions_access as boolean, - signalsAccess: row.signals_access as boolean, - backtestingAccess: row.backtesting_access as boolean, - apiAccess: row.api_access as boolean, - prioritySupport: row.priority_support as boolean, - coursesAccess: row.courses_access as SubscriptionPlan['coursesAccess'], - sortOrder: row.sort_order as number, - isFeatured: row.is_featured as boolean, - isActive: true, - createdAt: subscription.createdAt, - updatedAt: subscription.updatedAt, - }; - - return { ...subscription, plan }; - } - - async getSubscriptionHistory(userId: string): Promise { - const result = await db.query>( - `SELECT * FROM financial.subscriptions - WHERE user_id = $1 - ORDER BY created_at DESC`, - [userId] - ); - return result.rows.map(transformSubscription); - } - - async hasActiveSubscription(userId: string): Promise { - const result = await db.query<{ exists: boolean }>( - `SELECT EXISTS( - SELECT 1 FROM financial.subscriptions - WHERE user_id = $1 AND status IN ('active', 'trialing') - ) as exists`, - [userId] - ); - return result.rows[0].exists; - } - - // ========================================================================== - // Plan Queries - // ========================================================================== - - async getPlans(): Promise { - const result = await db.query>( - `SELECT * FROM financial.subscription_plans WHERE is_active = true ORDER BY sort_order` - ); - return result.rows.map(transformPlan); - } - - async getPlanById(id: string): Promise { - const result = await db.query>( - `SELECT * FROM financial.subscription_plans WHERE id = $1`, - [id] - ); - if (result.rows.length === 0) return null; - return transformPlan(result.rows[0]); - } - - async getPlanBySlug(slug: string): Promise { - const result = await db.query>( - `SELECT * FROM financial.subscription_plans WHERE slug = $1`, - [slug] - ); - if (result.rows.length === 0) return null; - return transformPlan(result.rows[0]); - } - - // ========================================================================== - // Subscription Management - // ========================================================================== - - async createSubscription(input: CreateSubscriptionInput): Promise { - // Check for existing active subscription - const existing = await this.getSubscriptionByUserId(input.userId); - if (existing && existing.status === 'active') { - throw new Error('User already has an active subscription'); - } - - // Get plan - const plan = await this.getPlanById(input.planId); - if (!plan) { - throw new Error('Plan not found'); - } - - const billingCycle = input.billingCycle || 'monthly'; - const price = billingCycle === 'yearly' ? (plan.priceYearly || plan.priceMonthly * 12) : plan.priceMonthly; - - // Calculate period - const now = new Date(); - const periodEnd = new Date(now); - if (billingCycle === 'yearly') { - periodEnd.setFullYear(periodEnd.getFullYear() + 1); - } else { - periodEnd.setMonth(periodEnd.getMonth() + 1); - } - - // Create subscription record - const result = await db.query>( - `INSERT INTO financial.subscriptions ( - user_id, plan_id, status, billing_cycle, current_period_start, - current_period_end, current_price, currency - ) VALUES ($1, $2, 'active', $3, $4, $5, $6, $7) - RETURNING *`, - [ - input.userId, - input.planId, - billingCycle, - now, - periodEnd, - price, - plan.currency, - ] - ); - - logger.info('[SubscriptionService] Subscription created:', { - userId: input.userId, - planId: input.planId, - billingCycle, - }); - - return transformSubscription(result.rows[0]); - } - - async updateSubscriptionFromStripe( - stripeSubscriptionId: string, - data: { - status?: SubscriptionStatus; - currentPeriodStart?: Date; - currentPeriodEnd?: Date; - cancelAtPeriodEnd?: boolean; - cancelledAt?: Date; - } - ): Promise { - const updates: string[] = []; - const params: (string | Date | boolean)[] = []; - let paramIndex = 1; - - if (data.status !== undefined) { - updates.push(`status = $${paramIndex++}`); - params.push(data.status); - } - if (data.currentPeriodStart !== undefined) { - updates.push(`current_period_start = $${paramIndex++}`); - params.push(data.currentPeriodStart); - } - if (data.currentPeriodEnd !== undefined) { - updates.push(`current_period_end = $${paramIndex++}`); - params.push(data.currentPeriodEnd); - } - if (data.cancelAtPeriodEnd !== undefined) { - updates.push(`cancel_at_period_end = $${paramIndex++}`); - params.push(data.cancelAtPeriodEnd); - } - if (data.cancelledAt !== undefined) { - updates.push(`cancelled_at = $${paramIndex++}`); - params.push(data.cancelledAt); - } - - if (updates.length === 0) return null; - - params.push(stripeSubscriptionId); - const result = await db.query>( - `UPDATE financial.subscriptions - SET ${updates.join(', ')} - WHERE stripe_subscription_id = $${paramIndex} - RETURNING *`, - params - ); - - if (result.rows.length === 0) return null; - return transformSubscription(result.rows[0]); - } - - async cancelSubscription( - userId: string, - immediately: boolean = false, - reason?: string - ): Promise { - const subscription = await this.getSubscriptionByUserId(userId); - if (!subscription) { - throw new Error('No active subscription found'); - } - - // Cancel in Stripe if applicable - if (subscription.stripeSubscriptionId) { - await stripeService.cancelSubscription(subscription.stripeSubscriptionId, immediately); - } - - // Update local record - if (immediately) { - const result = await db.query>( - `UPDATE financial.subscriptions - SET status = 'cancelled', - cancelled_at = CURRENT_TIMESTAMP, - cancellation_reason = $1 - WHERE id = $2 - RETURNING *`, - [reason, subscription.id] - ); - - logger.info('[SubscriptionService] Subscription cancelled immediately:', { - subscriptionId: subscription.id, - }); - - return transformSubscription(result.rows[0]); - } else { - const result = await db.query>( - `UPDATE financial.subscriptions - SET cancel_at_period_end = true, - cancellation_reason = $1 - WHERE id = $2 - RETURNING *`, - [reason, subscription.id] - ); - - logger.info('[SubscriptionService] Subscription set to cancel at period end:', { - subscriptionId: subscription.id, - }); - - return transformSubscription(result.rows[0]); - } - } - - async resumeSubscription(userId: string): Promise { - const subscription = await this.getSubscriptionByUserId(userId); - if (!subscription) { - throw new Error('No subscription found'); - } - - if (!subscription.cancelAtPeriodEnd) { - throw new Error('Subscription is not set to cancel'); - } - - // Resume in Stripe if applicable - if (subscription.stripeSubscriptionId) { - await stripeService.resumeSubscription(subscription.stripeSubscriptionId); - } - - // Update local record - const result = await db.query>( - `UPDATE financial.subscriptions - SET cancel_at_period_end = false, - cancellation_reason = NULL - WHERE id = $1 - RETURNING *`, - [subscription.id] - ); - - logger.info('[SubscriptionService] Subscription resumed:', { - subscriptionId: subscription.id, - }); - - return transformSubscription(result.rows[0]); - } - - async changePlan( - userId: string, - newPlanId: string, - billingCycle?: BillingCycle - ): Promise { - const subscription = await this.getSubscriptionByUserId(userId); - if (!subscription) { - throw new Error('No active subscription found'); - } - - const newPlan = await this.getPlanById(newPlanId); - if (!newPlan) { - throw new Error('Plan not found'); - } - - const newBillingCycle = billingCycle || subscription.billingCycle; - const priceId = newBillingCycle === 'yearly' - ? newPlan.stripePriceIdYearly - : newPlan.stripePriceIdMonthly; - - // Update in Stripe if applicable - if (subscription.stripeSubscriptionId && priceId) { - await stripeService.updateSubscriptionPlan(subscription.stripeSubscriptionId, priceId); - } - - // Calculate new price - const newPrice = newBillingCycle === 'yearly' - ? (newPlan.priceYearly || newPlan.priceMonthly * 12) - : newPlan.priceMonthly; - - // Update local record - const result = await db.query>( - `UPDATE financial.subscriptions - SET plan_id = $1, - billing_cycle = $2, - current_price = $3 - WHERE id = $4 - RETURNING *`, - [newPlanId, newBillingCycle, newPrice, subscription.id] - ); - - logger.info('[SubscriptionService] Subscription plan changed:', { - subscriptionId: subscription.id, - oldPlanId: subscription.planId, - newPlanId, - }); - - return transformSubscription(result.rows[0]); - } - - // ========================================================================== - // Feature Access Checks - // ========================================================================== - - async hasFeatureAccess(userId: string, feature: keyof SubscriptionPlan): Promise { - const subscription = await this.getSubscriptionByUserId(userId); - if (!subscription || !['active', 'trialing'].includes(subscription.status)) { - return false; - } - - const value = subscription.plan[feature]; - if (typeof value === 'boolean') { - return value; - } - if (typeof value === 'number') { - return value > 0; - } - if (typeof value === 'string') { - return value !== 'none'; - } - return false; - } - - async getUserPlanFeatures(userId: string): Promise | null> { - const subscription = await this.getSubscriptionByUserId(userId); - if (!subscription) return null; - - return { - name: subscription.plan.name, - slug: subscription.plan.slug, - maxWatchlists: subscription.plan.maxWatchlists, - maxAlerts: subscription.plan.maxAlerts, - mlPredictionsAccess: subscription.plan.mlPredictionsAccess, - signalsAccess: subscription.plan.signalsAccess, - backtestingAccess: subscription.plan.backtestingAccess, - apiAccess: subscription.plan.apiAccess, - prioritySupport: subscription.plan.prioritySupport, - coursesAccess: subscription.plan.coursesAccess, - }; - } - - // ========================================================================== - // Statistics - // ========================================================================== - - async getSubscriptionStats(): Promise<{ - totalActive: number; - totalTrialing: number; - totalCancelled: number; - monthlyRevenue: number; - planDistribution: { planId: string; planName: string; count: number }[]; - }> { - const statusResult = await db.query>( - `SELECT - COUNT(*) FILTER (WHERE status = 'active') as total_active, - COUNT(*) FILTER (WHERE status = 'trialing') as total_trialing, - COUNT(*) FILTER (WHERE status = 'cancelled') as total_cancelled, - COALESCE(SUM(current_price) FILTER (WHERE status = 'active' AND billing_cycle = 'monthly'), 0) as monthly_revenue - FROM financial.subscriptions` - ); - - const planResult = await db.query<{ plan_id: string; plan_name: string; count: string }>( - `SELECT s.plan_id, p.name as plan_name, COUNT(*) as count - FROM financial.subscriptions s - JOIN financial.subscription_plans p ON s.plan_id = p.id - WHERE s.status IN ('active', 'trialing') - GROUP BY s.plan_id, p.name - ORDER BY count DESC` - ); - - const stats = statusResult.rows[0]; - return { - totalActive: parseInt(stats.total_active, 10), - totalTrialing: parseInt(stats.total_trialing, 10), - totalCancelled: parseInt(stats.total_cancelled, 10), - monthlyRevenue: parseFloat(stats.monthly_revenue) || 0, - planDistribution: planResult.rows.map((row) => ({ - planId: row.plan_id, - planName: row.plan_name, - count: parseInt(row.count, 10), - })), - }; - } -} - -export const subscriptionService = new SubscriptionService(); diff --git a/apps/backend/src/modules/payments/services/wallet.service.ts b/apps/backend/src/modules/payments/services/wallet.service.ts deleted file mode 100644 index 98f32cf..0000000 --- a/apps/backend/src/modules/payments/services/wallet.service.ts +++ /dev/null @@ -1,632 +0,0 @@ -/** - * Wallet Service - * Handles internal wallet management, balances, and transactions - */ - -import { db } from '../../../shared/database'; -import { logger } from '../../../shared/utils/logger'; -import type { - Wallet, - WalletTransaction, - CreateWalletDepositInput, - CreateWalletWithdrawalInput, - TransactionType, - PaymentStatus, -} from '../types/payments.types'; - -// ============================================================================ -// Helper Functions -// ============================================================================ - -function transformWallet(row: Record): Wallet { - return { - id: row.id as string, - userId: row.user_id as string, - currency: row.currency as string, - balance: parseFloat(row.balance as string) || 0, - availableBalance: parseFloat(row.available_balance as string) || 0, - pendingBalance: parseFloat(row.pending_balance as string) || 0, - isActive: row.is_active as boolean, - dailyWithdrawalLimit: parseFloat(row.daily_withdrawal_limit as string) || 0, - monthlyWithdrawalLimit: parseFloat(row.monthly_withdrawal_limit as string) || 0, - createdAt: new Date(row.created_at as string), - updatedAt: new Date(row.updated_at as string), - }; -} - -function transformTransaction(row: Record): WalletTransaction { - return { - id: row.id as string, - walletId: row.wallet_id as string, - userId: row.user_id as string, - transactionType: row.transaction_type as TransactionType, - amount: parseFloat(row.amount as string) || 0, - currency: row.currency as string, - balanceBefore: parseFloat(row.balance_before as string) || 0, - balanceAfter: parseFloat(row.balance_after as string) || 0, - referenceType: row.reference_type as string | undefined, - referenceId: row.reference_id as string | undefined, - externalReference: row.external_reference as string | undefined, - description: row.description as string | undefined, - status: row.status as PaymentStatus, - metadata: row.metadata as Record | undefined, - createdAt: new Date(row.created_at as string), - }; -} - -// ============================================================================ -// Wallet Service Class -// ============================================================================ - -class WalletService { - // ========================================================================== - // Wallet Management - // ========================================================================== - - async getOrCreateWallet(userId: string, currency: string = 'USD'): Promise { - // Check if wallet exists - const existing = await db.query>( - `SELECT * FROM financial.wallets WHERE user_id = $1 AND currency = $2`, - [userId, currency] - ); - - if (existing.rows.length > 0) { - return transformWallet(existing.rows[0]); - } - - // Create new wallet - const result = await db.query>( - `INSERT INTO financial.wallets (user_id, currency) - VALUES ($1, $2) - RETURNING *`, - [userId, currency] - ); - - logger.info('[WalletService] Wallet created:', { userId, currency }); - return transformWallet(result.rows[0]); - } - - async getWalletByUserId(userId: string, currency: string = 'USD'): Promise { - const result = await db.query>( - `SELECT * FROM financial.wallets WHERE user_id = $1 AND currency = $2`, - [userId, currency] - ); - if (result.rows.length === 0) return null; - return transformWallet(result.rows[0]); - } - - async getWalletById(id: string): Promise { - const result = await db.query>( - `SELECT * FROM financial.wallets WHERE id = $1`, - [id] - ); - if (result.rows.length === 0) return null; - return transformWallet(result.rows[0]); - } - - async getUserWallets(userId: string): Promise { - const result = await db.query>( - `SELECT * FROM financial.wallets WHERE user_id = $1 ORDER BY currency`, - [userId] - ); - return result.rows.map(transformWallet); - } - - async getBalance(userId: string, currency: string = 'USD'): Promise { - const wallet = await this.getWalletByUserId(userId, currency); - return wallet?.availableBalance || 0; - } - - // ========================================================================== - // Transactions - // ========================================================================== - - async getTransactions( - userId: string, - options: { - walletId?: string; - transactionType?: TransactionType; - status?: PaymentStatus; - startDate?: Date; - endDate?: Date; - limit?: number; - offset?: number; - } = {} - ): Promise<{ transactions: WalletTransaction[]; total: number }> { - const conditions: string[] = ['user_id = $1']; - const params: (string | number | Date)[] = [userId]; - let paramIndex = 2; - - if (options.walletId) { - conditions.push(`wallet_id = $${paramIndex++}`); - params.push(options.walletId); - } - if (options.transactionType) { - conditions.push(`transaction_type = $${paramIndex++}`); - params.push(options.transactionType); - } - if (options.status) { - conditions.push(`status = $${paramIndex++}`); - params.push(options.status); - } - if (options.startDate) { - conditions.push(`created_at >= $${paramIndex++}`); - params.push(options.startDate); - } - if (options.endDate) { - conditions.push(`created_at <= $${paramIndex++}`); - params.push(options.endDate); - } - - const whereClause = conditions.join(' AND '); - const limit = options.limit || 50; - const offset = options.offset || 0; - - // Get total count - const countResult = await db.query<{ count: string }>( - `SELECT COUNT(*) FROM financial.wallet_transactions WHERE ${whereClause}`, - params - ); - - // Get transactions - const result = await db.query>( - `SELECT * FROM financial.wallet_transactions - WHERE ${whereClause} - ORDER BY created_at DESC - LIMIT ${limit} OFFSET ${offset}`, - params - ); - - return { - transactions: result.rows.map(transformTransaction), - total: parseInt(countResult.rows[0].count, 10), - }; - } - - async getTransactionById(id: string): Promise { - const result = await db.query>( - `SELECT * FROM financial.wallet_transactions WHERE id = $1`, - [id] - ); - if (result.rows.length === 0) return null; - return transformTransaction(result.rows[0]); - } - - // ========================================================================== - // Deposit Operations - // ========================================================================== - - async createDeposit(input: CreateWalletDepositInput): Promise { - const wallet = await this.getOrCreateWallet(input.userId, input.currency || 'USD'); - - // Use transaction for atomic operation - const client = await db.getClient(); - try { - await client.query('BEGIN'); - - const balanceBefore = wallet.balance; - const balanceAfter = balanceBefore + input.amount; - - // Create transaction record - const txResult = await client.query>( - `INSERT INTO financial.wallet_transactions ( - wallet_id, user_id, transaction_type, amount, currency, - balance_before, balance_after, description, status - ) VALUES ($1, $2, 'deposit', $3, $4, $5, $6, $7, 'pending') - RETURNING *`, - [ - wallet.id, - input.userId, - input.amount, - input.currency || 'USD', - balanceBefore, - balanceAfter, - input.description || 'Wallet deposit', - ] - ); - - // Update wallet pending balance - await client.query( - `UPDATE financial.wallets - SET pending_balance = pending_balance + $1 - WHERE id = $2`, - [input.amount, wallet.id] - ); - - await client.query('COMMIT'); - - logger.info('[WalletService] Deposit created:', { - userId: input.userId, - amount: input.amount, - transactionId: txResult.rows[0].id, - }); - - return transformTransaction(txResult.rows[0]); - } catch (error) { - await client.query('ROLLBACK'); - throw error; - } finally { - client.release(); - } - } - - async confirmDeposit(transactionId: string): Promise { - const transaction = await this.getTransactionById(transactionId); - if (!transaction) { - throw new Error('Transaction not found'); - } - if (transaction.status !== 'pending') { - throw new Error('Transaction is not pending'); - } - - const client = await db.getClient(); - try { - await client.query('BEGIN'); - - // Update transaction status - const txResult = await client.query>( - `UPDATE financial.wallet_transactions - SET status = 'succeeded' - WHERE id = $1 - RETURNING *`, - [transactionId] - ); - - // Update wallet balances - await client.query( - `UPDATE financial.wallets - SET balance = balance + $1, - available_balance = available_balance + $1, - pending_balance = pending_balance - $1 - WHERE id = $2`, - [transaction.amount, transaction.walletId] - ); - - await client.query('COMMIT'); - - logger.info('[WalletService] Deposit confirmed:', { transactionId }); - return transformTransaction(txResult.rows[0]); - } catch (error) { - await client.query('ROLLBACK'); - throw error; - } finally { - client.release(); - } - } - - // ========================================================================== - // Withdrawal Operations - // ========================================================================== - - async createWithdrawal(input: CreateWalletWithdrawalInput): Promise { - const wallet = await this.getWalletByUserId(input.userId, input.currency || 'USD'); - if (!wallet) { - throw new Error('Wallet not found'); - } - - if (wallet.availableBalance < input.amount) { - throw new Error('Insufficient balance'); - } - - // Check daily limit - const dailyWithdrawals = await this.getDailyWithdrawalTotal(input.userId, input.currency || 'USD'); - if (dailyWithdrawals + input.amount > wallet.dailyWithdrawalLimit) { - throw new Error('Daily withdrawal limit exceeded'); - } - - // Check monthly limit - const monthlyWithdrawals = await this.getMonthlyWithdrawalTotal(input.userId, input.currency || 'USD'); - if (monthlyWithdrawals + input.amount > wallet.monthlyWithdrawalLimit) { - throw new Error('Monthly withdrawal limit exceeded'); - } - - const client = await db.getClient(); - try { - await client.query('BEGIN'); - - const balanceBefore = wallet.balance; - const balanceAfter = balanceBefore - input.amount; - - // Create transaction record - const txResult = await client.query>( - `INSERT INTO financial.wallet_transactions ( - wallet_id, user_id, transaction_type, amount, currency, - balance_before, balance_after, description, status, metadata - ) VALUES ($1, $2, 'withdrawal', $3, $4, $5, $6, $7, 'pending', $8) - RETURNING *`, - [ - wallet.id, - input.userId, - input.amount, - input.currency || 'USD', - balanceBefore, - balanceAfter, - `Withdrawal via ${input.payoutMethod}`, - JSON.stringify({ - payoutMethod: input.payoutMethod, - destinationDetails: input.destinationDetails, - }), - ] - ); - - // Update wallet - reduce available balance - await client.query( - `UPDATE financial.wallets - SET available_balance = available_balance - $1, - pending_balance = pending_balance + $1 - WHERE id = $2`, - [input.amount, wallet.id] - ); - - await client.query('COMMIT'); - - logger.info('[WalletService] Withdrawal created:', { - userId: input.userId, - amount: input.amount, - transactionId: txResult.rows[0].id, - }); - - return transformTransaction(txResult.rows[0]); - } catch (error) { - await client.query('ROLLBACK'); - throw error; - } finally { - client.release(); - } - } - - async confirmWithdrawal(transactionId: string): Promise { - const transaction = await this.getTransactionById(transactionId); - if (!transaction) { - throw new Error('Transaction not found'); - } - if (transaction.status !== 'pending') { - throw new Error('Transaction is not pending'); - } - - const client = await db.getClient(); - try { - await client.query('BEGIN'); - - // Update transaction status - const txResult = await client.query>( - `UPDATE financial.wallet_transactions - SET status = 'succeeded' - WHERE id = $1 - RETURNING *`, - [transactionId] - ); - - // Update wallet balances - await client.query( - `UPDATE financial.wallets - SET balance = balance - $1, - pending_balance = pending_balance - $1 - WHERE id = $2`, - [transaction.amount, transaction.walletId] - ); - - await client.query('COMMIT'); - - logger.info('[WalletService] Withdrawal confirmed:', { transactionId }); - return transformTransaction(txResult.rows[0]); - } catch (error) { - await client.query('ROLLBACK'); - throw error; - } finally { - client.release(); - } - } - - async cancelWithdrawal(transactionId: string, reason?: string): Promise { - const transaction = await this.getTransactionById(transactionId); - if (!transaction) { - throw new Error('Transaction not found'); - } - if (transaction.status !== 'pending') { - throw new Error('Transaction is not pending'); - } - - const client = await db.getClient(); - try { - await client.query('BEGIN'); - - // Update transaction status - const txResult = await client.query>( - `UPDATE financial.wallet_transactions - SET status = 'cancelled', - metadata = jsonb_set(COALESCE(metadata, '{}'), '{cancellationReason}', $1) - WHERE id = $2 - RETURNING *`, - [JSON.stringify(reason || 'Cancelled'), transactionId] - ); - - // Restore available balance - await client.query( - `UPDATE financial.wallets - SET available_balance = available_balance + $1, - pending_balance = pending_balance - $1 - WHERE id = $2`, - [transaction.amount, transaction.walletId] - ); - - await client.query('COMMIT'); - - logger.info('[WalletService] Withdrawal cancelled:', { transactionId, reason }); - return transformTransaction(txResult.rows[0]); - } catch (error) { - await client.query('ROLLBACK'); - throw error; - } finally { - client.release(); - } - } - - // ========================================================================== - // Internal Transfers - // ========================================================================== - - async transfer( - fromUserId: string, - toUserId: string, - amount: number, - currency: string = 'USD', - description?: string - ): Promise<{ fromTransaction: WalletTransaction; toTransaction: WalletTransaction }> { - const fromWallet = await this.getWalletByUserId(fromUserId, currency); - if (!fromWallet) { - throw new Error('Source wallet not found'); - } - - if (fromWallet.availableBalance < amount) { - throw new Error('Insufficient balance'); - } - - const toWallet = await this.getOrCreateWallet(toUserId, currency); - - const client = await db.getClient(); - try { - await client.query('BEGIN'); - - // Create debit transaction - const fromResult = await client.query>( - `INSERT INTO financial.wallet_transactions ( - wallet_id, user_id, transaction_type, amount, currency, - balance_before, balance_after, reference_type, reference_id, description, status - ) VALUES ($1, $2, 'transfer', $3, $4, $5, $6, 'user', $7, $8, 'succeeded') - RETURNING *`, - [ - fromWallet.id, - fromUserId, - -amount, - currency, - fromWallet.balance, - fromWallet.balance - amount, - toUserId, - description || `Transfer to user ${toUserId}`, - ] - ); - - // Create credit transaction - const toResult = await client.query>( - `INSERT INTO financial.wallet_transactions ( - wallet_id, user_id, transaction_type, amount, currency, - balance_before, balance_after, reference_type, reference_id, description, status - ) VALUES ($1, $2, 'transfer', $3, $4, $5, $6, 'user', $7, $8, 'succeeded') - RETURNING *`, - [ - toWallet.id, - toUserId, - amount, - currency, - toWallet.balance, - toWallet.balance + amount, - fromUserId, - description || `Transfer from user ${fromUserId}`, - ] - ); - - // Update wallets - await client.query( - `UPDATE financial.wallets - SET balance = balance - $1, available_balance = available_balance - $1 - WHERE id = $2`, - [amount, fromWallet.id] - ); - - await client.query( - `UPDATE financial.wallets - SET balance = balance + $1, available_balance = available_balance + $1 - WHERE id = $2`, - [amount, toWallet.id] - ); - - await client.query('COMMIT'); - - logger.info('[WalletService] Transfer completed:', { - fromUserId, - toUserId, - amount, - currency, - }); - - return { - fromTransaction: transformTransaction(fromResult.rows[0]), - toTransaction: transformTransaction(toResult.rows[0]), - }; - } catch (error) { - await client.query('ROLLBACK'); - throw error; - } finally { - client.release(); - } - } - - // ========================================================================== - // Limit Checks - // ========================================================================== - - private async getDailyWithdrawalTotal(userId: string, currency: string): Promise { - const result = await db.query<{ total: string }>( - `SELECT COALESCE(SUM(ABS(amount)), 0) as total - FROM financial.wallet_transactions - WHERE user_id = $1 - AND currency = $2 - AND transaction_type = 'withdrawal' - AND status IN ('pending', 'succeeded') - AND created_at >= CURRENT_DATE`, - [userId, currency] - ); - return parseFloat(result.rows[0].total) || 0; - } - - private async getMonthlyWithdrawalTotal(userId: string, currency: string): Promise { - const result = await db.query<{ total: string }>( - `SELECT COALESCE(SUM(ABS(amount)), 0) as total - FROM financial.wallet_transactions - WHERE user_id = $1 - AND currency = $2 - AND transaction_type = 'withdrawal' - AND status IN ('pending', 'succeeded') - AND created_at >= DATE_TRUNC('month', CURRENT_DATE)`, - [userId, currency] - ); - return parseFloat(result.rows[0].total) || 0; - } - - // ========================================================================== - // Statistics - // ========================================================================== - - async getWalletStats(userId: string, currency: string = 'USD'): Promise<{ - totalDeposits: number; - totalWithdrawals: number; - totalTransfers: number; - transactionCount: number; - lastTransaction?: Date; - }> { - const result = await db.query>( - `SELECT - COALESCE(SUM(CASE WHEN transaction_type = 'deposit' AND status = 'succeeded' THEN amount ELSE 0 END), 0) as total_deposits, - COALESCE(SUM(CASE WHEN transaction_type = 'withdrawal' AND status = 'succeeded' THEN ABS(amount) ELSE 0 END), 0) as total_withdrawals, - COALESCE(SUM(CASE WHEN transaction_type = 'transfer' AND status = 'succeeded' THEN ABS(amount) ELSE 0 END), 0) as total_transfers, - COUNT(*) as transaction_count, - MAX(created_at) as last_transaction - FROM financial.wallet_transactions wt - JOIN financial.wallets w ON wt.wallet_id = w.id - WHERE w.user_id = $1 AND w.currency = $2`, - [userId, currency] - ); - - const stats = result.rows[0]; - return { - totalDeposits: parseFloat(stats.total_deposits) || 0, - totalWithdrawals: parseFloat(stats.total_withdrawals) || 0, - totalTransfers: parseFloat(stats.total_transfers) || 0, - transactionCount: parseInt(stats.transaction_count, 10), - lastTransaction: stats.last_transaction ? new Date(stats.last_transaction) : undefined, - }; - } -} - -export const walletService = new WalletService(); diff --git a/apps/backend/src/modules/payments/types/payments.types.ts b/apps/backend/src/modules/payments/types/payments.types.ts deleted file mode 100644 index 560cfb7..0000000 --- a/apps/backend/src/modules/payments/types/payments.types.ts +++ /dev/null @@ -1,324 +0,0 @@ -/** - * Payments Module Types - */ - -// ============================================================================ -// Enums -// ============================================================================ - -export type SubscriptionStatus = 'trialing' | 'active' | 'past_due' | 'cancelled' | 'unpaid' | 'paused'; -export type PaymentStatus = 'pending' | 'processing' | 'succeeded' | 'failed' | 'refunded' | 'cancelled'; -export type PaymentMethod = 'card' | 'bank_transfer' | 'paypal' | 'crypto' | 'wallet_balance'; -export type TransactionType = 'deposit' | 'withdrawal' | 'transfer' | 'payment' | 'refund' | 'fee' | 'distribution'; -export type BillingCycle = 'monthly' | 'yearly'; - -// ============================================================================ -// Subscription Plans -// ============================================================================ - -export interface PlanFeature { - name: string; - description?: string; - included: boolean; -} - -export interface SubscriptionPlan { - id: string; - name: string; - slug: string; - description?: string; - priceMonthly: number; - priceYearly?: number; - currency: string; - stripePriceIdMonthly?: string; - stripePriceIdYearly?: string; - stripeProductId?: string; - features: PlanFeature[]; - maxWatchlists?: number; - maxAlerts?: number; - mlPredictionsAccess: boolean; - signalsAccess: boolean; - backtestingAccess: boolean; - apiAccess: boolean; - prioritySupport: boolean; - coursesAccess: 'none' | 'free_only' | 'basic' | 'all'; - sortOrder: number; - isFeatured: boolean; - isActive: boolean; - createdAt: Date; - updatedAt: Date; -} - -// ============================================================================ -// Stripe Customer -// ============================================================================ - -export interface StripeCustomer { - id: string; - userId: string; - stripeCustomerId: string; - email?: string; - defaultPaymentMethodId?: string; - metadata?: Record; - createdAt: Date; - updatedAt: Date; -} - -// ============================================================================ -// Subscription -// ============================================================================ - -export interface Subscription { - id: string; - userId: string; - planId: string; - stripeSubscriptionId?: string; - stripeCustomerId?: string; - status: SubscriptionStatus; - billingCycle: BillingCycle; - currentPeriodStart?: Date; - currentPeriodEnd?: Date; - trialStart?: Date; - trialEnd?: Date; - cancelAtPeriodEnd: boolean; - cancelledAt?: Date; - cancellationReason?: string; - currentPrice?: number; - currency: string; - createdAt: Date; - updatedAt: Date; -} - -export interface SubscriptionWithPlan extends Subscription { - plan: SubscriptionPlan; -} - -export interface CreateSubscriptionInput { - userId: string; - planId: string; - billingCycle?: BillingCycle; - paymentMethodId?: string; - promoCode?: string; -} - -// ============================================================================ -// Wallet -// ============================================================================ - -export interface Wallet { - id: string; - userId: string; - currency: string; - balance: number; - availableBalance: number; - pendingBalance: number; - isActive: boolean; - dailyWithdrawalLimit: number; - monthlyWithdrawalLimit: number; - createdAt: Date; - updatedAt: Date; -} - -export interface WalletTransaction { - id: string; - walletId: string; - userId: string; - transactionType: TransactionType; - amount: number; - currency: string; - balanceBefore: number; - balanceAfter: number; - referenceType?: string; - referenceId?: string; - externalReference?: string; - description?: string; - status: PaymentStatus; - metadata?: Record; - createdAt: Date; -} - -export interface CreateWalletDepositInput { - userId: string; - amount: number; - currency?: string; - paymentMethodId?: string; - description?: string; -} - -export interface CreateWalletWithdrawalInput { - userId: string; - amount: number; - currency?: string; - payoutMethod: 'bank_transfer' | 'paypal' | 'crypto'; - destinationDetails: Record; -} - -// ============================================================================ -// Payment -// ============================================================================ - -export interface PaymentMethodDetails { - type: string; - last4?: string; - brand?: string; - expMonth?: number; - expYear?: number; -} - -export interface Payment { - id: string; - userId: string; - paymentType: string; - amount: number; - currency: string; - fee: number; - netAmount?: number; - paymentMethod?: PaymentMethod; - paymentMethodDetails?: PaymentMethodDetails; - stripePaymentIntentId?: string; - stripeChargeId?: string; - stripeInvoiceId?: string; - status: PaymentStatus; - failureReason?: string; - referenceType?: string; - referenceId?: string; - description?: string; - metadata?: Record; - ipAddress?: string; - invoiceUrl?: string; - receiptUrl?: string; - createdAt: Date; - updatedAt: Date; -} - -export interface CreatePaymentInput { - userId: string; - paymentType: string; - amount: number; - currency?: string; - paymentMethodId?: string; - referenceType?: string; - referenceId?: string; - description?: string; - metadata?: Record; -} - -// ============================================================================ -// Promo Codes -// ============================================================================ - -export interface PromoCode { - id: string; - code: string; - description?: string; - discountType: 'percentage' | 'fixed_amount'; - discountValue: number; - currency: string; - appliesTo: 'all' | 'subscription' | 'course'; - applicablePlanIds?: string[]; - applicableCourseIds?: string[]; - maxUses?: number; - currentUses: number; - maxUsesPerUser: number; - validFrom: Date; - validUntil?: Date; - minPurchaseAmount?: number; - firstTimeOnly: boolean; - isActive: boolean; - createdAt: Date; - updatedAt: Date; -} - -export interface ValidatePromoCodeResult { - valid: boolean; - code?: PromoCode; - discountAmount?: number; - error?: string; -} - -// ============================================================================ -// Payout Request -// ============================================================================ - -export interface PayoutRequest { - id: string; - userId: string; - walletId: string; - amount: number; - currency: string; - fee: number; - netAmount?: number; - payoutMethod: string; - destinationDetails: Record; - status: 'pending' | 'processing' | 'completed' | 'rejected' | 'cancelled'; - processedAt?: Date; - completedAt?: Date; - processedBy?: string; - rejectionReason?: string; - externalReference?: string; - walletTransactionId?: string; - createdAt: Date; - updatedAt: Date; -} - -// ============================================================================ -// Invoice -// ============================================================================ - -export interface InvoiceLineItem { - description: string; - quantity: number; - unitPrice: number; - amount: number; -} - -export interface Invoice { - id: string; - userId: string; - invoiceNumber: string; - stripeInvoiceId?: string; - subtotal: number; - tax: number; - total: number; - amountPaid: number; - amountDue?: number; - currency: string; - status: 'draft' | 'open' | 'paid' | 'void' | 'uncollectible'; - dueDate?: Date; - paidAt?: Date; - lineItems: InvoiceLineItem[]; - pdfUrl?: string; - hostedInvoiceUrl?: string; - billingDetails?: Record; - createdAt: Date; - updatedAt: Date; -} - -// ============================================================================ -// Checkout Session -// ============================================================================ - -export interface CheckoutSession { - sessionId: string; - url: string; - expiresAt: Date; -} - -export interface CreateCheckoutSessionInput { - userId: string; - planId?: string; - courseId?: string; - billingCycle?: BillingCycle; - successUrl: string; - cancelUrl: string; - promoCode?: string; -} - -// ============================================================================ -// Billing Portal -// ============================================================================ - -export interface BillingPortalSession { - url: string; - returnUrl: string; -} diff --git a/apps/backend/src/modules/portfolio/controllers/portfolio.controller.ts b/apps/backend/src/modules/portfolio/controllers/portfolio.controller.ts deleted file mode 100644 index e8f8cf0..0000000 --- a/apps/backend/src/modules/portfolio/controllers/portfolio.controller.ts +++ /dev/null @@ -1,460 +0,0 @@ -/** - * Portfolio Controller - * Handles portfolio management endpoints - */ - -import { Request, Response, NextFunction } from 'express'; -import { portfolioService, RiskProfile } from '../services/portfolio.service'; - -// ============================================================================ -// Types -// ============================================================================ - -// Use Request directly - user is already declared globally in auth.middleware.ts -type AuthRequest = Request; - -// ============================================================================ -// Portfolio Management -// ============================================================================ - -/** - * Create a new portfolio - */ -export async function createPortfolio(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const { name, riskProfile, initialValue } = req.body; - - if (!name || !riskProfile) { - res.status(400).json({ - success: false, - error: { message: 'Name and risk profile are required', code: 'VALIDATION_ERROR' }, - }); - return; - } - - const validProfiles: RiskProfile[] = ['conservative', 'moderate', 'aggressive']; - if (!validProfiles.includes(riskProfile)) { - res.status(400).json({ - success: false, - error: { message: 'Invalid risk profile', code: 'VALIDATION_ERROR' }, - }); - return; - } - - const portfolio = await portfolioService.createPortfolio( - userId, - name, - riskProfile, - initialValue || 0 - ); - - res.status(201).json({ - success: true, - data: portfolio, - }); - } catch (error) { - next(error); - } -} - -/** - * Get user's portfolios - */ -export async function getPortfolios(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const portfolios = await portfolioService.getUserPortfolios(userId); - - res.json({ - success: true, - data: portfolios, - }); - } catch (error) { - next(error); - } -} - -/** - * Get portfolio by ID - */ -export async function getPortfolio(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const { portfolioId } = req.params; - - const portfolio = await portfolioService.getPortfolio(portfolioId); - if (!portfolio) { - res.status(404).json({ - success: false, - error: { message: 'Portfolio not found', code: 'NOT_FOUND' }, - }); - return; - } - - if (portfolio.userId !== userId) { - res.status(403).json({ - success: false, - error: { message: 'Forbidden', code: 'FORBIDDEN' }, - }); - return; - } - - res.json({ - success: true, - data: portfolio, - }); - } catch (error) { - next(error); - } -} - -/** - * Update portfolio allocations - */ -export async function updateAllocations(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const { portfolioId } = req.params; - const { allocations } = req.body; - - if (!allocations || !Array.isArray(allocations)) { - res.status(400).json({ - success: false, - error: { message: 'Allocations array is required', code: 'VALIDATION_ERROR' }, - }); - return; - } - - const portfolio = await portfolioService.getPortfolio(portfolioId); - if (!portfolio) { - res.status(404).json({ - success: false, - error: { message: 'Portfolio not found', code: 'NOT_FOUND' }, - }); - return; - } - - if (portfolio.userId !== userId) { - res.status(403).json({ - success: false, - error: { message: 'Forbidden', code: 'FORBIDDEN' }, - }); - return; - } - - const updated = await portfolioService.updateAllocations(portfolioId, allocations); - - res.json({ - success: true, - data: updated, - }); - } catch (error) { - next(error); - } -} - -/** - * Get rebalancing recommendations - */ -export async function getRebalanceRecommendations(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const { portfolioId } = req.params; - - const portfolio = await portfolioService.getPortfolio(portfolioId); - if (!portfolio) { - res.status(404).json({ - success: false, - error: { message: 'Portfolio not found', code: 'NOT_FOUND' }, - }); - return; - } - - if (portfolio.userId !== userId) { - res.status(403).json({ - success: false, - error: { message: 'Forbidden', code: 'FORBIDDEN' }, - }); - return; - } - - const recommendations = await portfolioService.getRebalanceRecommendations(portfolioId); - - res.json({ - success: true, - data: recommendations, - }); - } catch (error) { - next(error); - } -} - -/** - * Execute rebalancing - */ -export async function executeRebalance(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const { portfolioId } = req.params; - - const portfolio = await portfolioService.getPortfolio(portfolioId); - if (!portfolio) { - res.status(404).json({ - success: false, - error: { message: 'Portfolio not found', code: 'NOT_FOUND' }, - }); - return; - } - - if (portfolio.userId !== userId) { - res.status(403).json({ - success: false, - error: { message: 'Forbidden', code: 'FORBIDDEN' }, - }); - return; - } - - const rebalanced = await portfolioService.executeRebalance(portfolioId); - - res.json({ - success: true, - data: rebalanced, - message: 'Portfolio rebalanced successfully', - }); - } catch (error) { - next(error); - } -} - -/** - * Get portfolio statistics - */ -export async function getPortfolioStats(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const { portfolioId } = req.params; - - const portfolio = await portfolioService.getPortfolio(portfolioId); - if (!portfolio) { - res.status(404).json({ - success: false, - error: { message: 'Portfolio not found', code: 'NOT_FOUND' }, - }); - return; - } - - if (portfolio.userId !== userId) { - res.status(403).json({ - success: false, - error: { message: 'Forbidden', code: 'FORBIDDEN' }, - }); - return; - } - - const stats = await portfolioService.getPortfolioStats(portfolioId); - - res.json({ - success: true, - data: stats, - }); - } catch (error) { - next(error); - } -} - -// ============================================================================ -// Goal Management -// ============================================================================ - -/** - * Create a financial goal - */ -export async function createGoal(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const { name, targetAmount, targetDate, monthlyContribution } = req.body; - - if (!name || !targetAmount || !targetDate || !monthlyContribution) { - res.status(400).json({ - success: false, - error: { message: 'All fields are required', code: 'VALIDATION_ERROR' }, - }); - return; - } - - const goal = await portfolioService.createGoal( - userId, - name, - Number(targetAmount), - new Date(targetDate), - Number(monthlyContribution) - ); - - res.status(201).json({ - success: true, - data: goal, - }); - } catch (error) { - next(error); - } -} - -/** - * Get user's goals - */ -export async function getGoals(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const goals = await portfolioService.getUserGoals(userId); - - res.json({ - success: true, - data: goals, - }); - } catch (error) { - next(error); - } -} - -/** - * Update goal progress - */ -export async function updateGoalProgress(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const { goalId } = req.params; - const { currentAmount } = req.body; - - if (currentAmount === undefined) { - res.status(400).json({ - success: false, - error: { message: 'Current amount is required', code: 'VALIDATION_ERROR' }, - }); - return; - } - - const goal = await portfolioService.updateGoalProgress(goalId, Number(currentAmount)); - - res.json({ - success: true, - data: goal, - }); - } catch (error) { - next(error); - } -} - -/** - * Delete a goal - */ -export async function deleteGoal(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const { goalId } = req.params; - - const deleted = await portfolioService.deleteGoal(goalId); - if (!deleted) { - res.status(404).json({ - success: false, - error: { message: 'Goal not found', code: 'NOT_FOUND' }, - }); - return; - } - - res.json({ - success: true, - message: 'Goal deleted', - }); - } catch (error) { - next(error); - } -} diff --git a/apps/backend/src/modules/portfolio/portfolio.routes.ts b/apps/backend/src/modules/portfolio/portfolio.routes.ts deleted file mode 100644 index 7de80f8..0000000 --- a/apps/backend/src/modules/portfolio/portfolio.routes.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Portfolio Routes - * Portfolio management and goal tracking endpoints - */ - -import { Router, RequestHandler } from 'express'; -import * as portfolioController from './controllers/portfolio.controller'; - -const router = Router(); - -// Type cast helper for authenticated routes -// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -const authHandler = (fn: Function): RequestHandler => fn as RequestHandler; - -// ============================================================================ -// Portfolio Management (Authenticated) -// ============================================================================ - -/** - * POST /api/v1/portfolio - * Create a new portfolio - * Body: { name, riskProfile, initialValue? } - */ -router.post('/', authHandler(portfolioController.createPortfolio)); - -/** - * GET /api/v1/portfolio - * Get user's portfolios - */ -router.get('/', authHandler(portfolioController.getPortfolios)); - -/** - * GET /api/v1/portfolio/:portfolioId - * Get portfolio by ID with allocations - */ -router.get('/:portfolioId', authHandler(portfolioController.getPortfolio)); - -/** - * PUT /api/v1/portfolio/:portfolioId/allocations - * Update portfolio allocations - * Body: { allocations: [{ asset, targetPercent }] } - */ -router.put('/:portfolioId/allocations', authHandler(portfolioController.updateAllocations)); - -/** - * GET /api/v1/portfolio/:portfolioId/stats - * Get portfolio statistics - */ -router.get('/:portfolioId/stats', authHandler(portfolioController.getPortfolioStats)); - -// ============================================================================ -// Rebalancing (Authenticated) -// ============================================================================ - -/** - * GET /api/v1/portfolio/:portfolioId/rebalance - * Get rebalancing recommendations - */ -router.get('/:portfolioId/rebalance', authHandler(portfolioController.getRebalanceRecommendations)); - -/** - * POST /api/v1/portfolio/:portfolioId/rebalance - * Execute rebalancing - */ -router.post('/:portfolioId/rebalance', authHandler(portfolioController.executeRebalance)); - -// ============================================================================ -// Goals (Authenticated) -// ============================================================================ - -/** - * POST /api/v1/portfolio/goals - * Create a financial goal - * Body: { name, targetAmount, targetDate, monthlyContribution } - */ -router.post('/goals', authHandler(portfolioController.createGoal)); - -/** - * GET /api/v1/portfolio/goals - * Get user's goals - */ -router.get('/goals', authHandler(portfolioController.getGoals)); - -/** - * PATCH /api/v1/portfolio/goals/:goalId - * Update goal progress - * Body: { currentAmount } - */ -router.patch('/goals/:goalId', authHandler(portfolioController.updateGoalProgress)); - -/** - * DELETE /api/v1/portfolio/goals/:goalId - * Delete a goal - */ -router.delete('/goals/:goalId', authHandler(portfolioController.deleteGoal)); - -export { router as portfolioRouter }; diff --git a/apps/backend/src/modules/portfolio/services/__tests__/portfolio.service.spec.ts b/apps/backend/src/modules/portfolio/services/__tests__/portfolio.service.spec.ts deleted file mode 100644 index 2d1ecc6..0000000 --- a/apps/backend/src/modules/portfolio/services/__tests__/portfolio.service.spec.ts +++ /dev/null @@ -1,585 +0,0 @@ -/** - * Portfolio Service Unit Tests - * - * Tests for portfolio service including: - * - Portfolio creation and management - * - Asset allocation and rebalancing - * - Portfolio statistics and goals - * - Performance tracking - */ - -import { mockDb, createMockQueryResult, resetDatabaseMocks } from '../../../../__tests__/mocks/database.mock'; - -// Mock database (portfolio service uses in-memory storage, but may use DB in future) -jest.mock('../../../../shared/database', () => ({ - db: mockDb, -})); - -// Mock logger -jest.mock('../../../../shared/utils/logger', () => ({ - logger: { - info: jest.fn(), - error: jest.fn(), - warn: jest.fn(), - debug: jest.fn(), - }, -})); - -// Mock market service -const mockGetPrice = jest.fn(); -const mockGetPrices = jest.fn(); -jest.mock('../../trading/services/market.service', () => ({ - marketService: { - getPrice: mockGetPrice, - getPrices: mockGetPrices, - }, -})); - -// Import service after mocks -import { portfolioService } from '../portfolio.service'; - -describe('PortfolioService', () => { - beforeEach(() => { - resetDatabaseMocks(); - mockGetPrice.mockReset(); - mockGetPrices.mockReset(); - // Clear in-memory storage - jest.clearAllMocks(); - }); - - describe('createPortfolio', () => { - it('should create conservative portfolio with default allocations', async () => { - const result = await portfolioService.createPortfolio({ - userId: 'user-123', - name: 'Conservative Portfolio', - riskProfile: 'conservative', - }); - - expect(result.userId).toBe('user-123'); - expect(result.name).toBe('Conservative Portfolio'); - expect(result.riskProfile).toBe('conservative'); - expect(result.allocations).toBeDefined(); - expect(result.allocations.length).toBeGreaterThan(0); - }); - - it('should create moderate portfolio with balanced allocations', async () => { - const result = await portfolioService.createPortfolio({ - userId: 'user-123', - name: 'Moderate Portfolio', - riskProfile: 'moderate', - }); - - expect(result.riskProfile).toBe('moderate'); - expect(result.allocations.length).toBeGreaterThanOrEqual(3); - }); - - it('should create aggressive portfolio with high-risk allocations', async () => { - const result = await portfolioService.createPortfolio({ - userId: 'user-123', - name: 'Aggressive Portfolio', - riskProfile: 'aggressive', - }); - - expect(result.riskProfile).toBe('aggressive'); - expect(result.allocations).toBeDefined(); - }); - - it('should create portfolio with custom allocations', async () => { - const customAllocations = [ - { asset: 'BTC', targetPercent: 60 }, - { asset: 'ETH', targetPercent: 30 }, - { asset: 'USDT', targetPercent: 10 }, - ]; - - const result = await portfolioService.createPortfolio({ - userId: 'user-123', - name: 'Custom Portfolio', - riskProfile: 'moderate', - customAllocations, - }); - - expect(result.allocations.length).toBe(3); - expect(result.allocations[0].asset).toBe('BTC'); - expect(result.allocations[0].targetPercent).toBe(60); - }); - - it('should validate total allocation equals 100%', async () => { - const invalidAllocations = [ - { asset: 'BTC', targetPercent: 60 }, - { asset: 'ETH', targetPercent: 30 }, - ]; - - await expect( - portfolioService.createPortfolio({ - userId: 'user-123', - name: 'Invalid Portfolio', - riskProfile: 'moderate', - customAllocations: invalidAllocations, - }) - ).rejects.toThrow('Allocations must total 100%'); - }); - }); - - describe('getUserPortfolios', () => { - it('should retrieve all portfolios for a user', async () => { - await portfolioService.createPortfolio({ - userId: 'user-123', - name: 'Portfolio 1', - riskProfile: 'conservative', - }); - - await portfolioService.createPortfolio({ - userId: 'user-123', - name: 'Portfolio 2', - riskProfile: 'aggressive', - }); - - const result = await portfolioService.getUserPortfolios('user-123'); - - expect(result).toHaveLength(2); - expect(result[0].userId).toBe('user-123'); - expect(result[1].userId).toBe('user-123'); - }); - - it('should return empty array for user with no portfolios', async () => { - const result = await portfolioService.getUserPortfolios('user-999'); - - expect(result).toEqual([]); - }); - }); - - describe('getPortfolioById', () => { - it('should retrieve a specific portfolio by ID', async () => { - const created = await portfolioService.createPortfolio({ - userId: 'user-123', - name: 'Test Portfolio', - riskProfile: 'moderate', - }); - - const result = await portfolioService.getPortfolioById(created.id); - - expect(result).toBeDefined(); - expect(result?.id).toBe(created.id); - expect(result?.name).toBe('Test Portfolio'); - }); - - it('should return null for non-existent portfolio', async () => { - const result = await portfolioService.getPortfolioById('non-existent-id'); - - expect(result).toBeNull(); - }); - }); - - describe('updatePortfolio', () => { - it('should update portfolio name', async () => { - const created = await portfolioService.createPortfolio({ - userId: 'user-123', - name: 'Original Name', - riskProfile: 'moderate', - }); - - const result = await portfolioService.updatePortfolio(created.id, { - name: 'Updated Name', - }); - - expect(result.name).toBe('Updated Name'); - }); - - it('should update risk profile', async () => { - const created = await portfolioService.createPortfolio({ - userId: 'user-123', - name: 'Test Portfolio', - riskProfile: 'conservative', - }); - - const result = await portfolioService.updatePortfolio(created.id, { - riskProfile: 'aggressive', - }); - - expect(result.riskProfile).toBe('aggressive'); - }); - - it('should update allocations', async () => { - const created = await portfolioService.createPortfolio({ - userId: 'user-123', - name: 'Test Portfolio', - riskProfile: 'moderate', - }); - - const newAllocations = [ - { asset: 'BTC', targetPercent: 70 }, - { asset: 'ETH', targetPercent: 30 }, - ]; - - const result = await portfolioService.updatePortfolio(created.id, { - allocations: newAllocations, - }); - - expect(result.allocations.length).toBe(2); - expect(result.allocations[0].targetPercent).toBe(70); - }); - }); - - describe('deletePortfolio', () => { - it('should delete a portfolio', async () => { - const created = await portfolioService.createPortfolio({ - userId: 'user-123', - name: 'To Delete', - riskProfile: 'moderate', - }); - - await portfolioService.deletePortfolio(created.id); - - const result = await portfolioService.getPortfolioById(created.id); - expect(result).toBeNull(); - }); - - it('should handle deletion of non-existent portfolio', async () => { - await expect( - portfolioService.deletePortfolio('non-existent-id') - ).rejects.toThrow('Portfolio not found'); - }); - }); - - describe('getPortfolioValue', () => { - it('should calculate total portfolio value', async () => { - mockGetPrices.mockResolvedValueOnce({ - BTC: 50000, - ETH: 3000, - USDT: 1, - }); - - const portfolio = await portfolioService.createPortfolio({ - userId: 'user-123', - name: 'Test Portfolio', - riskProfile: 'moderate', - }); - - // Add some holdings - await portfolioService.addHolding(portfolio.id, { - asset: 'BTC', - quantity: 0.5, - cost: 24000, - }); - - await portfolioService.addHolding(portfolio.id, { - asset: 'ETH', - quantity: 2, - cost: 5800, - }); - - const result = await portfolioService.getPortfolioValue(portfolio.id); - - expect(result.totalValue).toBeGreaterThan(0); - expect(result.totalCost).toBe(29800); - expect(result.unrealizedPnl).toBeDefined(); - }); - - it('should handle portfolio with no holdings', async () => { - const portfolio = await portfolioService.createPortfolio({ - userId: 'user-123', - name: 'Empty Portfolio', - riskProfile: 'moderate', - }); - - const result = await portfolioService.getPortfolioValue(portfolio.id); - - expect(result.totalValue).toBe(0); - expect(result.totalCost).toBe(0); - }); - - it('should handle market data fetch errors gracefully', async () => { - mockGetPrices.mockRejectedValueOnce(new Error('Market data unavailable')); - - const portfolio = await portfolioService.createPortfolio({ - userId: 'user-123', - name: 'Test Portfolio', - riskProfile: 'moderate', - }); - - await expect( - portfolioService.getPortfolioValue(portfolio.id) - ).rejects.toThrow('Market data unavailable'); - }); - }); - - describe('getRebalanceRecommendations', () => { - it('should recommend rebalancing when allocations deviate', async () => { - mockGetPrices.mockResolvedValueOnce({ - BTC: 60000, - ETH: 3500, - USDT: 1, - }); - - const portfolio = await portfolioService.createPortfolio({ - userId: 'user-123', - name: 'Test Portfolio', - riskProfile: 'moderate', - customAllocations: [ - { asset: 'BTC', targetPercent: 50 }, - { asset: 'ETH', targetPercent: 30 }, - { asset: 'USDT', targetPercent: 20 }, - ], - }); - - // Add holdings that deviate from target - await portfolioService.addHolding(portfolio.id, { - asset: 'BTC', - quantity: 1, - cost: 50000, - }); - - const result = await portfolioService.getRebalanceRecommendations(portfolio.id); - - expect(result).toBeDefined(); - expect(result.length).toBeGreaterThan(0); - expect(result[0]).toHaveProperty('action'); - expect(result[0]).toHaveProperty('amount'); - }); - - it('should not recommend rebalancing when allocations are balanced', async () => { - mockGetPrices.mockResolvedValueOnce({ - BTC: 50000, - ETH: 3000, - USDT: 1, - }); - - const portfolio = await portfolioService.createPortfolio({ - userId: 'user-123', - name: 'Balanced Portfolio', - riskProfile: 'moderate', - }); - - const result = await portfolioService.getRebalanceRecommendations(portfolio.id); - - expect(result).toBeDefined(); - expect(result.filter(r => r.action !== 'hold')).toHaveLength(0); - }); - - it('should prioritize high-deviation assets', async () => { - mockGetPrices.mockResolvedValueOnce({ - BTC: 70000, - ETH: 3000, - USDT: 1, - }); - - const portfolio = await portfolioService.createPortfolio({ - userId: 'user-123', - name: 'Test Portfolio', - riskProfile: 'moderate', - }); - - const result = await portfolioService.getRebalanceRecommendations(portfolio.id); - - const highPriority = result.filter(r => r.priority === 'high'); - expect(highPriority.length).toBeGreaterThanOrEqual(0); - }); - }); - - describe('createPortfolioGoal', () => { - it('should create a new portfolio goal', async () => { - const portfolio = await portfolioService.createPortfolio({ - userId: 'user-123', - name: 'Test Portfolio', - riskProfile: 'moderate', - }); - - const result = await portfolioService.createPortfolioGoal({ - userId: 'user-123', - portfolioId: portfolio.id, - name: 'Retirement Fund', - targetAmount: 1000000, - targetDate: new Date('2045-01-01'), - monthlyContribution: 1000, - }); - - expect(result.name).toBe('Retirement Fund'); - expect(result.targetAmount).toBe(1000000); - expect(result.monthlyContribution).toBe(1000); - }); - - it('should calculate goal progress', async () => { - const portfolio = await portfolioService.createPortfolio({ - userId: 'user-123', - name: 'Test Portfolio', - riskProfile: 'moderate', - }); - - const result = await portfolioService.createPortfolioGoal({ - userId: 'user-123', - portfolioId: portfolio.id, - name: 'House Down Payment', - targetAmount: 100000, - targetDate: new Date('2026-01-01'), - monthlyContribution: 2000, - currentAmount: 25000, - }); - - expect(result.progress).toBe(25); - expect(result.status).toBeDefined(); - }); - }); - - describe('getPortfolioStats', () => { - it('should calculate portfolio statistics', async () => { - mockGetPrices.mockResolvedValue({ - BTC: 50000, - ETH: 3000, - }); - - const portfolio = await portfolioService.createPortfolio({ - userId: 'user-123', - name: 'Test Portfolio', - riskProfile: 'moderate', - }); - - await portfolioService.addHolding(portfolio.id, { - asset: 'BTC', - quantity: 0.5, - cost: 24000, - }); - - await portfolioService.addHolding(portfolio.id, { - asset: 'ETH', - quantity: 2, - cost: 5800, - }); - - const result = await portfolioService.getPortfolioStats(portfolio.id); - - expect(result.totalValue).toBeGreaterThan(0); - expect(result).toHaveProperty('dayChange'); - expect(result).toHaveProperty('weekChange'); - expect(result).toHaveProperty('monthChange'); - expect(result).toHaveProperty('allTimeChange'); - expect(result).toHaveProperty('bestPerformer'); - expect(result).toHaveProperty('worstPerformer'); - }); - - it('should handle portfolio with single asset', async () => { - mockGetPrices.mockResolvedValue({ BTC: 50000 }); - - const portfolio = await portfolioService.createPortfolio({ - userId: 'user-123', - name: 'BTC Only', - riskProfile: 'aggressive', - }); - - await portfolioService.addHolding(portfolio.id, { - asset: 'BTC', - quantity: 1, - cost: 45000, - }); - - const result = await portfolioService.getPortfolioStats(portfolio.id); - - expect(result.totalValue).toBe(50000); - expect(result.bestPerformer.asset).toBe('BTC'); - }); - }); - - describe('addHolding', () => { - it('should add a new holding to portfolio', async () => { - const portfolio = await portfolioService.createPortfolio({ - userId: 'user-123', - name: 'Test Portfolio', - riskProfile: 'moderate', - }); - - const result = await portfolioService.addHolding(portfolio.id, { - asset: 'BTC', - quantity: 0.5, - cost: 25000, - }); - - expect(result.asset).toBe('BTC'); - expect(result.quantity).toBe(0.5); - expect(result.cost).toBe(25000); - }); - - it('should update existing holding when adding to same asset', async () => { - const portfolio = await portfolioService.createPortfolio({ - userId: 'user-123', - name: 'Test Portfolio', - riskProfile: 'moderate', - }); - - await portfolioService.addHolding(portfolio.id, { - asset: 'BTC', - quantity: 0.5, - cost: 25000, - }); - - const result = await portfolioService.addHolding(portfolio.id, { - asset: 'BTC', - quantity: 0.3, - cost: 16000, - }); - - expect(result.quantity).toBe(0.8); - expect(result.cost).toBe(41000); - }); - }); - - describe('removeHolding', () => { - it('should remove a holding from portfolio', async () => { - const portfolio = await portfolioService.createPortfolio({ - userId: 'user-123', - name: 'Test Portfolio', - riskProfile: 'moderate', - }); - - await portfolioService.addHolding(portfolio.id, { - asset: 'BTC', - quantity: 0.5, - cost: 25000, - }); - - await portfolioService.removeHolding(portfolio.id, 'BTC', 0.5); - - const updated = await portfolioService.getPortfolioById(portfolio.id); - const btcHolding = updated?.allocations.find(a => a.asset === 'BTC'); - - expect(btcHolding?.quantity).toBe(0); - }); - - it('should handle partial removal of holding', async () => { - const portfolio = await portfolioService.createPortfolio({ - userId: 'user-123', - name: 'Test Portfolio', - riskProfile: 'moderate', - }); - - await portfolioService.addHolding(portfolio.id, { - asset: 'ETH', - quantity: 5, - cost: 15000, - }); - - await portfolioService.removeHolding(portfolio.id, 'ETH', 2); - - const updated = await portfolioService.getPortfolioById(portfolio.id); - const ethHolding = updated?.allocations.find(a => a.asset === 'ETH'); - - expect(ethHolding?.quantity).toBe(3); - }); - - it('should prevent removing more than available quantity', async () => { - const portfolio = await portfolioService.createPortfolio({ - userId: 'user-123', - name: 'Test Portfolio', - riskProfile: 'moderate', - }); - - await portfolioService.addHolding(portfolio.id, { - asset: 'SOL', - quantity: 10, - cost: 1000, - }); - - await expect( - portfolioService.removeHolding(portfolio.id, 'SOL', 15) - ).rejects.toThrow('Insufficient quantity'); - }); - }); -}); diff --git a/apps/backend/src/modules/portfolio/services/portfolio.service.ts b/apps/backend/src/modules/portfolio/services/portfolio.service.ts deleted file mode 100644 index 14c73e5..0000000 --- a/apps/backend/src/modules/portfolio/services/portfolio.service.ts +++ /dev/null @@ -1,501 +0,0 @@ -/** - * Portfolio Service - * Manages user portfolios, allocations, and rebalancing - */ - -import { v4 as uuidv4 } from 'uuid'; -import { marketService } from '../../trading/services/market.service'; - -// ============================================================================ -// Types -// ============================================================================ - -export type RiskProfile = 'conservative' | 'moderate' | 'aggressive'; - -export interface Portfolio { - id: string; - userId: string; - name: string; - riskProfile: RiskProfile; - allocations: PortfolioAllocation[]; - totalValue: number; - totalCost: number; - unrealizedPnl: number; - unrealizedPnlPercent: number; - realizedPnl: number; - lastRebalanced: Date | null; - createdAt: Date; - updatedAt: Date; -} - -export interface PortfolioAllocation { - id: string; - portfolioId: string; - asset: string; - targetPercent: number; - currentPercent: number; - quantity: number; - value: number; - cost: number; - pnl: number; - pnlPercent: number; - deviation: number; -} - -export interface PortfolioGoal { - id: string; - userId: string; - name: string; - targetAmount: number; - currentAmount: number; - targetDate: Date; - monthlyContribution: number; - progress: number; - projectedCompletion: Date | null; - status: 'on_track' | 'at_risk' | 'behind'; - createdAt: Date; - updatedAt: Date; -} - -export interface RebalanceRecommendation { - asset: string; - currentPercent: number; - targetPercent: number; - action: 'buy' | 'sell' | 'hold'; - amount: number; - amountUSD: number; - priority: 'high' | 'medium' | 'low'; -} - -export interface PortfolioStats { - totalValue: number; - dayChange: number; - dayChangePercent: number; - weekChange: number; - weekChangePercent: number; - monthChange: number; - monthChangePercent: number; - allTimeChange: number; - allTimeChangePercent: number; - bestPerformer: { asset: string; change: number }; - worstPerformer: { asset: string; change: number }; -} - -// ============================================================================ -// Default Allocations by Risk Profile -// ============================================================================ - -const DEFAULT_ALLOCATIONS: Record = { - conservative: [ - { asset: 'USDT', percent: 50 }, - { asset: 'BTC', percent: 30 }, - { asset: 'ETH', percent: 20 }, - ], - moderate: [ - { asset: 'USDT', percent: 20 }, - { asset: 'BTC', percent: 40 }, - { asset: 'ETH', percent: 25 }, - { asset: 'SOL', percent: 10 }, - { asset: 'LINK', percent: 5 }, - ], - aggressive: [ - { asset: 'USDT', percent: 10 }, - { asset: 'BTC', percent: 30 }, - { asset: 'ETH', percent: 25 }, - { asset: 'SOL', percent: 15 }, - { asset: 'LINK', percent: 10 }, - { asset: 'AVAX', percent: 10 }, - ], -}; - -// ============================================================================ -// In-Memory Storage -// ============================================================================ - -const portfolios: Map = new Map(); -const goals: Map = new Map(); - -// ============================================================================ -// Portfolio Service -// ============================================================================ - -class PortfolioService { - // ========================================================================== - // Portfolio Management - // ========================================================================== - - /** - * Create a new portfolio - */ - async createPortfolio( - userId: string, - name: string, - riskProfile: RiskProfile, - initialValue: number = 0 - ): Promise { - const defaultAllocations = DEFAULT_ALLOCATIONS[riskProfile]; - - const portfolio: Portfolio = { - id: uuidv4(), - userId, - name, - riskProfile, - allocations: defaultAllocations.map((a) => ({ - id: uuidv4(), - portfolioId: '', - asset: a.asset, - targetPercent: a.percent, - currentPercent: a.percent, - quantity: 0, - value: (initialValue * a.percent) / 100, - cost: (initialValue * a.percent) / 100, - pnl: 0, - pnlPercent: 0, - deviation: 0, - })), - totalValue: initialValue, - totalCost: initialValue, - unrealizedPnl: 0, - unrealizedPnlPercent: 0, - realizedPnl: 0, - lastRebalanced: null, - createdAt: new Date(), - updatedAt: new Date(), - }; - - // Set portfolio ID in allocations - portfolio.allocations.forEach((a) => { - a.portfolioId = portfolio.id; - }); - - portfolios.set(portfolio.id, portfolio); - return portfolio; - } - - /** - * Get portfolio by ID - */ - async getPortfolio(portfolioId: string): Promise { - const portfolio = portfolios.get(portfolioId); - if (!portfolio) return null; - - // Update current values - await this.updatePortfolioValues(portfolio); - - return portfolio; - } - - /** - * Get user portfolios - */ - async getUserPortfolios(userId: string): Promise { - const userPortfolios = Array.from(portfolios.values()) - .filter((p) => p.userId === userId); - - // Update values for all portfolios - await Promise.all(userPortfolios.map((p) => this.updatePortfolioValues(p))); - - return userPortfolios; - } - - /** - * Update portfolio allocations - */ - async updateAllocations( - portfolioId: string, - allocations: { asset: string; targetPercent: number }[] - ): Promise { - const portfolio = portfolios.get(portfolioId); - if (!portfolio) { - throw new Error(`Portfolio not found: ${portfolioId}`); - } - - // Validate total is 100% - const total = allocations.reduce((sum, a) => sum + a.targetPercent, 0); - if (Math.abs(total - 100) > 0.01) { - throw new Error('Allocations must sum to 100%'); - } - - // Update allocations - portfolio.allocations = allocations.map((a) => { - const existing = portfolio.allocations.find((e) => e.asset === a.asset); - return { - id: existing?.id || uuidv4(), - portfolioId, - asset: a.asset, - targetPercent: a.targetPercent, - currentPercent: existing?.currentPercent || 0, - quantity: existing?.quantity || 0, - value: existing?.value || 0, - cost: existing?.cost || 0, - pnl: existing?.pnl || 0, - pnlPercent: existing?.pnlPercent || 0, - deviation: 0, - }; - }); - - portfolio.updatedAt = new Date(); - await this.updatePortfolioValues(portfolio); - - return portfolio; - } - - /** - * Get rebalancing recommendations - */ - async getRebalanceRecommendations(portfolioId: string): Promise { - const portfolio = await this.getPortfolio(portfolioId); - if (!portfolio) { - throw new Error(`Portfolio not found: ${portfolioId}`); - } - - const recommendations: RebalanceRecommendation[] = []; - const rebalanceThreshold = 5; // 5% deviation triggers rebalance - - for (const allocation of portfolio.allocations) { - const deviation = allocation.currentPercent - allocation.targetPercent; - const absDeviation = Math.abs(deviation); - - if (absDeviation < rebalanceThreshold) { - recommendations.push({ - asset: allocation.asset, - currentPercent: allocation.currentPercent, - targetPercent: allocation.targetPercent, - action: 'hold', - amount: 0, - amountUSD: 0, - priority: 'low', - }); - continue; - } - - const targetValue = (portfolio.totalValue * allocation.targetPercent) / 100; - const difference = targetValue - allocation.value; - - recommendations.push({ - asset: allocation.asset, - currentPercent: allocation.currentPercent, - targetPercent: allocation.targetPercent, - action: difference > 0 ? 'buy' : 'sell', - amount: Math.abs(difference / (allocation.value / allocation.quantity || 1)), - amountUSD: Math.abs(difference), - priority: absDeviation > 10 ? 'high' : 'medium', - }); - } - - // Sort by priority and absolute deviation - return recommendations.sort((a, b) => { - const priorityOrder = { high: 0, medium: 1, low: 2 }; - return priorityOrder[a.priority] - priorityOrder[b.priority]; - }); - } - - /** - * Execute rebalancing - */ - async executeRebalance(portfolioId: string): Promise { - const portfolio = await this.getPortfolio(portfolioId); - if (!portfolio) { - throw new Error(`Portfolio not found: ${portfolioId}`); - } - - // Simulate rebalancing by adjusting allocations to target - for (const allocation of portfolio.allocations) { - allocation.currentPercent = allocation.targetPercent; - allocation.deviation = 0; - } - - portfolio.lastRebalanced = new Date(); - portfolio.updatedAt = new Date(); - - return portfolio; - } - - /** - * Get portfolio statistics - */ - async getPortfolioStats(portfolioId: string): Promise { - const portfolio = await this.getPortfolio(portfolioId); - if (!portfolio) { - throw new Error(`Portfolio not found: ${portfolioId}`); - } - - // Calculate performance (mock data for now) - const dayChange = portfolio.totalValue * 0.02; // 2% daily change - const weekChange = portfolio.totalValue * 0.05; - const monthChange = portfolio.totalValue * 0.12; - - // Find best and worst performers - const sortedAllocations = [...portfolio.allocations].sort( - (a, b) => b.pnlPercent - a.pnlPercent - ); - - return { - totalValue: portfolio.totalValue, - dayChange, - dayChangePercent: (dayChange / portfolio.totalValue) * 100, - weekChange, - weekChangePercent: (weekChange / portfolio.totalValue) * 100, - monthChange, - monthChangePercent: (monthChange / portfolio.totalValue) * 100, - allTimeChange: portfolio.unrealizedPnl + portfolio.realizedPnl, - allTimeChangePercent: portfolio.unrealizedPnlPercent, - bestPerformer: { - asset: sortedAllocations[0]?.asset || 'N/A', - change: sortedAllocations[0]?.pnlPercent || 0, - }, - worstPerformer: { - asset: sortedAllocations[sortedAllocations.length - 1]?.asset || 'N/A', - change: sortedAllocations[sortedAllocations.length - 1]?.pnlPercent || 0, - }, - }; - } - - // ========================================================================== - // Goal Management - // ========================================================================== - - /** - * Create a financial goal - */ - async createGoal( - userId: string, - name: string, - targetAmount: number, - targetDate: Date, - monthlyContribution: number - ): Promise { - const goal: PortfolioGoal = { - id: uuidv4(), - userId, - name, - targetAmount, - currentAmount: 0, - targetDate, - monthlyContribution, - progress: 0, - projectedCompletion: null, - status: 'on_track', - createdAt: new Date(), - updatedAt: new Date(), - }; - - this.updateGoalProjection(goal); - goals.set(goal.id, goal); - - return goal; - } - - /** - * Get user goals - */ - async getUserGoals(userId: string): Promise { - return Array.from(goals.values()) - .filter((g) => g.userId === userId) - .map((g) => { - this.updateGoalProjection(g); - return g; - }); - } - - /** - * Update goal progress - */ - async updateGoalProgress( - goalId: string, - currentAmount: number - ): Promise { - const goal = goals.get(goalId); - if (!goal) { - throw new Error(`Goal not found: ${goalId}`); - } - - goal.currentAmount = currentAmount; - goal.progress = (currentAmount / goal.targetAmount) * 100; - goal.updatedAt = new Date(); - - this.updateGoalProjection(goal); - - return goal; - } - - /** - * Delete a goal - */ - async deleteGoal(goalId: string): Promise { - return goals.delete(goalId); - } - - // ========================================================================== - // Private Methods - // ========================================================================== - - private async updatePortfolioValues(portfolio: Portfolio): Promise { - let totalValue = 0; - - for (const allocation of portfolio.allocations) { - if (allocation.asset === 'USDT') { - // Stablecoin, value = quantity - allocation.value = allocation.quantity; - } else { - try { - const price = await marketService.getPrice(`${allocation.asset}USDT`); - allocation.value = allocation.quantity * price.price; - } catch { - // Keep existing value if price fetch fails - } - } - - allocation.pnl = allocation.value - allocation.cost; - allocation.pnlPercent = - allocation.cost > 0 ? (allocation.pnl / allocation.cost) * 100 : 0; - - totalValue += allocation.value; - } - - portfolio.totalValue = totalValue; - portfolio.unrealizedPnl = totalValue - portfolio.totalCost; - portfolio.unrealizedPnlPercent = - portfolio.totalCost > 0 - ? (portfolio.unrealizedPnl / portfolio.totalCost) * 100 - : 0; - - // Update current percentages and deviations - for (const allocation of portfolio.allocations) { - allocation.currentPercent = - totalValue > 0 ? (allocation.value / totalValue) * 100 : 0; - allocation.deviation = allocation.currentPercent - allocation.targetPercent; - } - - portfolio.updatedAt = new Date(); - } - - private updateGoalProjection(goal: PortfolioGoal): void { - const monthsRemaining = Math.max( - 0, - (goal.targetDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24 * 30) - ); - - const amountNeeded = goal.targetAmount - goal.currentAmount; - const projectedFinalAmount = - goal.currentAmount + goal.monthlyContribution * monthsRemaining; - - if (projectedFinalAmount >= goal.targetAmount) { - goal.status = 'on_track'; - // Calculate when goal will be reached - const monthsToGoal = amountNeeded / goal.monthlyContribution; - goal.projectedCompletion = new Date( - Date.now() + monthsToGoal * 30 * 24 * 60 * 60 * 1000 - ); - } else if (projectedFinalAmount >= goal.targetAmount * 0.8) { - goal.status = 'at_risk'; - goal.projectedCompletion = null; - } else { - goal.status = 'behind'; - goal.projectedCompletion = null; - } - } -} - -// Export singleton instance -export const portfolioService = new PortfolioService(); diff --git a/apps/backend/src/modules/trading/controllers/alerts.controller.ts b/apps/backend/src/modules/trading/controllers/alerts.controller.ts deleted file mode 100644 index 30eb6ae..0000000 --- a/apps/backend/src/modules/trading/controllers/alerts.controller.ts +++ /dev/null @@ -1,189 +0,0 @@ -/** - * Price Alerts Controller - * Handles price alert endpoints - */ - -import type { Request, Response, NextFunction } from 'express'; -import { alertsService, AlertCondition } from '../services/alerts.service'; -import type { AuthenticatedRequest } from '../../../core/guards/auth.guard'; - -// ============================================================================ -// CRUD Operations -// ============================================================================ - -export async function createAlert(req: Request, res: Response, next: NextFunction): Promise { - try { - const authReq = req as AuthenticatedRequest; - const { symbol, condition, price, note, notifyEmail, notifyPush, isRecurring } = req.body; - - if (!symbol || !condition || price === undefined) { - res.status(400).json({ - success: false, - error: 'Symbol, condition, and price are required', - }); - return; - } - - const validConditions: AlertCondition[] = ['above', 'below', 'crosses_above', 'crosses_below']; - if (!validConditions.includes(condition)) { - res.status(400).json({ - success: false, - error: `Invalid condition. Must be one of: ${validConditions.join(', ')}`, - }); - return; - } - - const alert = await alertsService.createAlert({ - userId: authReq.user.id, - symbol, - condition, - price, - note, - notifyEmail, - notifyPush, - isRecurring, - }); - - res.status(201).json({ success: true, data: alert }); - } catch (error) { - next(error); - } -} - -export async function getAlerts(req: Request, res: Response, next: NextFunction): Promise { - try { - const authReq = req as AuthenticatedRequest; - const { isActive, symbol, condition } = req.query; - - const alerts = await alertsService.getUserAlerts(authReq.user.id, { - isActive: isActive === 'true' ? true : isActive === 'false' ? false : undefined, - symbol: symbol as string, - condition: condition as AlertCondition, - }); - - res.json({ success: true, data: alerts }); - } catch (error) { - next(error); - } -} - -export async function getAlertById(req: Request, res: Response, next: NextFunction): Promise { - try { - const authReq = req as AuthenticatedRequest; - const { alertId } = req.params; - - const alert = await alertsService.getAlertById(alertId); - - if (!alert) { - res.status(404).json({ success: false, error: 'Alert not found' }); - return; - } - - if (alert.userId !== authReq.user.id) { - res.status(403).json({ success: false, error: 'Unauthorized' }); - return; - } - - res.json({ success: true, data: alert }); - } catch (error) { - next(error); - } -} - -export async function updateAlert(req: Request, res: Response, next: NextFunction): Promise { - try { - const authReq = req as AuthenticatedRequest; - const { alertId } = req.params; - const { price, note, notifyEmail, notifyPush, isRecurring, isActive } = req.body; - - const alert = await alertsService.updateAlert(alertId, authReq.user.id, { - price, - note, - notifyEmail, - notifyPush, - isRecurring, - isActive, - }); - - if (!alert) { - res.status(404).json({ success: false, error: 'Alert not found or unauthorized' }); - return; - } - - res.json({ success: true, data: alert }); - } catch (error) { - next(error); - } -} - -export async function deleteAlert(req: Request, res: Response, next: NextFunction): Promise { - try { - const authReq = req as AuthenticatedRequest; - const { alertId } = req.params; - - const deleted = await alertsService.deleteAlert(alertId, authReq.user.id); - - if (!deleted) { - res.status(404).json({ success: false, error: 'Alert not found or unauthorized' }); - return; - } - - res.json({ success: true, message: 'Alert deleted' }); - } catch (error) { - next(error); - } -} - -// ============================================================================ -// Enable/Disable -// ============================================================================ - -export async function enableAlert(req: Request, res: Response, next: NextFunction): Promise { - try { - const authReq = req as AuthenticatedRequest; - const { alertId } = req.params; - - const alert = await alertsService.enableAlert(alertId, authReq.user.id); - - if (!alert) { - res.status(404).json({ success: false, error: 'Alert not found or unauthorized' }); - return; - } - - res.json({ success: true, data: alert }); - } catch (error) { - next(error); - } -} - -export async function disableAlert(req: Request, res: Response, next: NextFunction): Promise { - try { - const authReq = req as AuthenticatedRequest; - const { alertId } = req.params; - - const alert = await alertsService.disableAlert(alertId, authReq.user.id); - - if (!alert) { - res.status(404).json({ success: false, error: 'Alert not found or unauthorized' }); - return; - } - - res.json({ success: true, data: alert }); - } catch (error) { - next(error); - } -} - -// ============================================================================ -// Statistics -// ============================================================================ - -export async function getAlertStats(req: Request, res: Response, next: NextFunction): Promise { - try { - const authReq = req as AuthenticatedRequest; - const stats = await alertsService.getUserAlertStats(authReq.user.id); - res.json({ success: true, data: stats }); - } catch (error) { - next(error); - } -} diff --git a/apps/backend/src/modules/trading/controllers/indicators.controller.ts b/apps/backend/src/modules/trading/controllers/indicators.controller.ts deleted file mode 100644 index 3a33699..0000000 --- a/apps/backend/src/modules/trading/controllers/indicators.controller.ts +++ /dev/null @@ -1,177 +0,0 @@ -/** - * Technical Indicators Controller - * Handles indicator calculation endpoints - */ - -import type { Request, Response, NextFunction } from 'express'; -import { indicatorsService } from '../services/indicators.service'; -import type { Interval } from '../services/binance.service'; - -// ============================================================================ -// Moving Averages -// ============================================================================ - -export async function getSMA(req: Request, res: Response, next: NextFunction): Promise { - try { - const { symbol } = req.params; - const interval = (req.query.interval as Interval) || '1h'; - const period = parseInt(req.query.period as string, 10) || 20; - const limit = parseInt(req.query.limit as string, 10) || 100; - - const data = await indicatorsService.getSMA({ symbol, interval, period, limit }); - res.json({ success: true, data }); - } catch (error) { - next(error); - } -} - -export async function getEMA(req: Request, res: Response, next: NextFunction): Promise { - try { - const { symbol } = req.params; - const interval = (req.query.interval as Interval) || '1h'; - const period = parseInt(req.query.period as string, 10) || 20; - const limit = parseInt(req.query.limit as string, 10) || 100; - - const data = await indicatorsService.getEMA({ symbol, interval, period, limit }); - res.json({ success: true, data }); - } catch (error) { - next(error); - } -} - -// ============================================================================ -// Oscillators -// ============================================================================ - -export async function getRSI(req: Request, res: Response, next: NextFunction): Promise { - try { - const { symbol } = req.params; - const interval = (req.query.interval as Interval) || '1h'; - const period = parseInt(req.query.period as string, 10) || 14; - const limit = parseInt(req.query.limit as string, 10) || 100; - - const data = await indicatorsService.getRSI({ symbol, interval, period, limit }); - res.json({ success: true, data }); - } catch (error) { - next(error); - } -} - -export async function getMACD(req: Request, res: Response, next: NextFunction): Promise { - try { - const { symbol } = req.params; - const interval = (req.query.interval as Interval) || '1h'; - const fastPeriod = parseInt(req.query.fastPeriod as string, 10) || 12; - const slowPeriod = parseInt(req.query.slowPeriod as string, 10) || 26; - const signalPeriod = parseInt(req.query.signalPeriod as string, 10) || 9; - const limit = parseInt(req.query.limit as string, 10) || 100; - - const data = await indicatorsService.getMACD({ - symbol, - interval, - fastPeriod, - slowPeriod, - signalPeriod, - limit, - }); - res.json({ success: true, data }); - } catch (error) { - next(error); - } -} - -export async function getStochastic(req: Request, res: Response, next: NextFunction): Promise { - try { - const { symbol } = req.params; - const interval = (req.query.interval as Interval) || '1h'; - const kPeriod = parseInt(req.query.kPeriod as string, 10) || 14; - const dPeriod = parseInt(req.query.dPeriod as string, 10) || 3; - const smoothK = parseInt(req.query.smoothK as string, 10) || 3; - const limit = parseInt(req.query.limit as string, 10) || 100; - - const data = await indicatorsService.getStochastic({ - symbol, - interval, - kPeriod, - dPeriod, - smoothK, - limit, - }); - res.json({ success: true, data }); - } catch (error) { - next(error); - } -} - -// ============================================================================ -// Volatility Indicators -// ============================================================================ - -export async function getBollingerBands(req: Request, res: Response, next: NextFunction): Promise { - try { - const { symbol } = req.params; - const interval = (req.query.interval as Interval) || '1h'; - const period = parseInt(req.query.period as string, 10) || 20; - const stdDev = parseFloat(req.query.stdDev as string) || 2; - const limit = parseInt(req.query.limit as string, 10) || 100; - - const data = await indicatorsService.getBollingerBands({ - symbol, - interval, - period, - stdDev, - limit, - }); - res.json({ success: true, data }); - } catch (error) { - next(error); - } -} - -export async function getATR(req: Request, res: Response, next: NextFunction): Promise { - try { - const { symbol } = req.params; - const interval = (req.query.interval as Interval) || '1h'; - const period = parseInt(req.query.period as string, 10) || 14; - const limit = parseInt(req.query.limit as string, 10) || 100; - - const data = await indicatorsService.getATR({ symbol, interval, period, limit }); - res.json({ success: true, data }); - } catch (error) { - next(error); - } -} - -// ============================================================================ -// Volume Indicators -// ============================================================================ - -export async function getVWAP(req: Request, res: Response, next: NextFunction): Promise { - try { - const { symbol } = req.params; - const interval = (req.query.interval as Interval) || '1h'; - const limit = parseInt(req.query.limit as string, 10) || 100; - - const data = await indicatorsService.getVWAP({ symbol, interval, limit }); - res.json({ success: true, data }); - } catch (error) { - next(error); - } -} - -// ============================================================================ -// All-in-One -// ============================================================================ - -export async function getAllIndicators(req: Request, res: Response, next: NextFunction): Promise { - try { - const { symbol } = req.params; - const interval = (req.query.interval as Interval) || '1h'; - const limit = parseInt(req.query.limit as string, 10) || 100; - - const data = await indicatorsService.getAllIndicators(symbol, interval, limit); - res.json({ success: true, data }); - } catch (error) { - next(error); - } -} diff --git a/apps/backend/src/modules/trading/controllers/paper-trading.controller.ts b/apps/backend/src/modules/trading/controllers/paper-trading.controller.ts deleted file mode 100644 index a742164..0000000 --- a/apps/backend/src/modules/trading/controllers/paper-trading.controller.ts +++ /dev/null @@ -1,253 +0,0 @@ -/** - * Paper Trading Controller - * Handles paper trading account and position endpoints - */ - -import type { Request, Response, NextFunction } from 'express'; -import { paperTradingService, TradeDirection } from '../services/paper-trading.service'; -import type { AuthenticatedRequest } from '../../../core/guards/auth.guard'; - -// ============================================================================ -// Account Endpoints -// ============================================================================ - -export async function getAccount(req: Request, res: Response, next: NextFunction): Promise { - try { - const authReq = req as AuthenticatedRequest; - const account = await paperTradingService.getOrCreateAccount(authReq.user.id); - res.json({ success: true, data: account }); - } catch (error) { - next(error); - } -} - -export async function getAccounts(req: Request, res: Response, next: NextFunction): Promise { - try { - const authReq = req as AuthenticatedRequest; - const accounts = await paperTradingService.getUserAccounts(authReq.user.id); - res.json({ success: true, data: accounts }); - } catch (error) { - next(error); - } -} - -export async function createAccount(req: Request, res: Response, next: NextFunction): Promise { - try { - const authReq = req as AuthenticatedRequest; - const { name, initialBalance, currency } = req.body; - - const account = await paperTradingService.createAccount(authReq.user.id, { - name, - initialBalance, - currency, - }); - - res.status(201).json({ success: true, data: account }); - } catch (error) { - next(error); - } -} - -export async function resetAccount(req: Request, res: Response, next: NextFunction): Promise { - try { - const authReq = req as AuthenticatedRequest; - const { accountId } = req.params; - - const account = await paperTradingService.resetAccount(accountId, authReq.user.id); - - if (!account) { - res.status(404).json({ success: false, error: 'Account not found or unauthorized' }); - return; - } - - res.json({ success: true, data: account, message: 'Account reset successfully' }); - } catch (error) { - next(error); - } -} - -export async function getAccountSummary(req: Request, res: Response, next: NextFunction): Promise { - try { - const authReq = req as AuthenticatedRequest; - const { accountId } = req.params; - - const summary = await paperTradingService.getAccountSummary(authReq.user.id, accountId); - - if (!summary) { - res.status(404).json({ success: false, error: 'Account not found' }); - return; - } - - res.json({ success: true, data: summary }); - } catch (error) { - next(error); - } -} - -// ============================================================================ -// Position Endpoints -// ============================================================================ - -export async function openPosition(req: Request, res: Response, next: NextFunction): Promise { - try { - const authReq = req as AuthenticatedRequest; - const { symbol, direction, lotSize, entryPrice, stopLoss, takeProfit } = req.body; - - if (!symbol || !direction || !lotSize) { - res.status(400).json({ - success: false, - error: 'Symbol, direction, and lotSize are required', - }); - return; - } - - const validDirections: TradeDirection[] = ['long', 'short']; - if (!validDirections.includes(direction)) { - res.status(400).json({ - success: false, - error: `Invalid direction. Must be one of: ${validDirections.join(', ')}`, - }); - return; - } - - if (lotSize <= 0) { - res.status(400).json({ - success: false, - error: 'Lot size must be greater than 0', - }); - return; - } - - const position = await paperTradingService.openPosition(authReq.user.id, { - symbol, - direction, - lotSize, - entryPrice, - stopLoss, - takeProfit, - }); - - res.status(201).json({ success: true, data: position }); - } catch (error) { - next(error); - } -} - -export async function closePosition(req: Request, res: Response, next: NextFunction): Promise { - try { - const authReq = req as AuthenticatedRequest; - const { positionId } = req.params; - const { exitPrice, closeReason } = req.body; - - const position = await paperTradingService.closePosition(positionId, authReq.user.id, { - exitPrice, - closeReason, - }); - - if (!position) { - res.status(404).json({ success: false, error: 'Position not found or already closed' }); - return; - } - - res.json({ success: true, data: position }); - } catch (error) { - next(error); - } -} - -export async function getPosition(req: Request, res: Response, next: NextFunction): Promise { - try { - const authReq = req as AuthenticatedRequest; - const { positionId } = req.params; - - const position = await paperTradingService.getPosition(positionId, authReq.user.id); - - if (!position) { - res.status(404).json({ success: false, error: 'Position not found' }); - return; - } - - res.json({ success: true, data: position }); - } catch (error) { - next(error); - } -} - -export async function getPositions(req: Request, res: Response, next: NextFunction): Promise { - try { - const authReq = req as AuthenticatedRequest; - const { accountId, status, symbol, limit } = req.query; - - const positions = await paperTradingService.getPositions(authReq.user.id, { - accountId: accountId as string, - status: status as 'open' | 'closed' | 'pending', - symbol: symbol as string, - limit: limit ? parseInt(limit as string, 10) : undefined, - }); - - res.json({ success: true, data: positions }); - } catch (error) { - next(error); - } -} - -export async function updatePosition(req: Request, res: Response, next: NextFunction): Promise { - try { - const authReq = req as AuthenticatedRequest; - const { positionId } = req.params; - const { stopLoss, takeProfit } = req.body; - - const position = await paperTradingService.updatePosition(positionId, authReq.user.id, { - stopLoss, - takeProfit, - }); - - if (!position) { - res.status(404).json({ success: false, error: 'Position not found or not open' }); - return; - } - - res.json({ success: true, data: position }); - } catch (error) { - next(error); - } -} - -// ============================================================================ -// Trade History & Analytics -// ============================================================================ - -export async function getTradeHistory(req: Request, res: Response, next: NextFunction): Promise { - try { - const authReq = req as AuthenticatedRequest; - const { accountId, symbol, startDate, endDate, limit } = req.query; - - const trades = await paperTradingService.getTradeHistory(authReq.user.id, { - accountId: accountId as string, - symbol: symbol as string, - startDate: startDate ? new Date(startDate as string) : undefined, - endDate: endDate ? new Date(endDate as string) : undefined, - limit: limit ? parseInt(limit as string, 10) : undefined, - }); - - res.json({ success: true, data: trades }); - } catch (error) { - next(error); - } -} - -export async function getPerformanceStats(req: Request, res: Response, next: NextFunction): Promise { - try { - const authReq = req as AuthenticatedRequest; - const { accountId } = req.query; - - const stats = await paperTradingService.getPerformanceStats( - authReq.user.id, - accountId as string | undefined - ); - - res.json({ success: true, data: stats }); - } catch (error) { - next(error); - } -} diff --git a/apps/backend/src/modules/trading/controllers/trading.controller.ts b/apps/backend/src/modules/trading/controllers/trading.controller.ts deleted file mode 100644 index 70d87a2..0000000 --- a/apps/backend/src/modules/trading/controllers/trading.controller.ts +++ /dev/null @@ -1,629 +0,0 @@ -/** - * Trading Controller - * Handles market data and paper trading endpoints - */ - -import { Request, Response, NextFunction } from 'express'; -import { marketService } from '../services/market.service'; -import { paperTradingService, PositionStatus, TradeDirection } from '../services/paper-trading.service'; -import { Interval } from '../services/binance.service'; - -// ============================================================================ -// Types -// ============================================================================ - -// Use Request directly - user is already declared globally in auth.middleware.ts -type AuthRequest = Request; - -// ============================================================================ -// Market Data Controllers -// ============================================================================ - -/** - * Get candlestick/kline data - */ -export async function getKlines(req: Request, res: Response, next: NextFunction): Promise { - try { - const { symbol } = req.params; - const { interval = '1h', startTime, endTime, limit } = req.query; - - const klines = await marketService.getKlines(symbol, interval as Interval, { - startTime: startTime ? Number(startTime) : undefined, - endTime: endTime ? Number(endTime) : undefined, - limit: limit ? Number(limit) : undefined, - }); - - res.json({ - success: true, - data: klines, - }); - } catch (error) { - next(error); - } -} - -/** - * Get current price - */ -export async function getPrice(req: Request, res: Response, next: NextFunction): Promise { - try { - const { symbol } = req.params; - - const price = await marketService.getPrice(symbol); - - res.json({ - success: true, - data: price, - }); - } catch (error) { - next(error); - } -} - -/** - * Get prices for multiple symbols - */ -export async function getPrices(req: Request, res: Response, next: NextFunction): Promise { - try { - const { symbols } = req.query; - const symbolList = symbols ? (symbols as string).split(',') : undefined; - - const prices = await marketService.getPrices(symbolList); - - res.json({ - success: true, - data: prices, - }); - } catch (error) { - next(error); - } -} - -/** - * Get 24h ticker - */ -export async function getTicker(req: Request, res: Response, next: NextFunction): Promise { - try { - const { symbol } = req.params; - - const ticker = await marketService.getTicker(symbol); - - res.json({ - success: true, - data: ticker, - }); - } catch (error) { - next(error); - } -} - -/** - * Get tickers for multiple symbols - */ -export async function getTickers(req: Request, res: Response, next: NextFunction): Promise { - try { - const { symbols } = req.query; - const symbolList = symbols ? (symbols as string).split(',') : undefined; - - const tickers = await marketService.getTickers(symbolList); - - res.json({ - success: true, - data: tickers, - }); - } catch (error) { - next(error); - } -} - -/** - * Get order book - */ -export async function getOrderBook(req: Request, res: Response, next: NextFunction): Promise { - try { - const { symbol } = req.params; - const { limit = 20 } = req.query; - - const orderBook = await marketService.getOrderBook(symbol, Number(limit)); - - res.json({ - success: true, - data: orderBook, - }); - } catch (error) { - next(error); - } -} - -/** - * Search symbols - */ -export async function searchSymbols(req: Request, res: Response, next: NextFunction): Promise { - try { - const { query = '', limit = 20 } = req.query; - - const symbols = marketService.searchSymbols(query as string, Number(limit)); - - res.json({ - success: true, - data: symbols, - }); - } catch (error) { - next(error); - } -} - -/** - * Get popular symbols - */ -export async function getPopularSymbols(req: Request, res: Response, next: NextFunction): Promise { - try { - const symbols = marketService.getPopularSymbols(); - const tickers = await marketService.getTickers(symbols); - - res.json({ - success: true, - data: tickers, - }); - } catch (error) { - next(error); - } -} - -/** - * Get watchlist data - */ -export async function getWatchlist(req: Request, res: Response, next: NextFunction): Promise { - try { - const { symbols } = req.query; - if (!symbols) { - res.status(400).json({ - success: false, - error: { message: 'Symbols parameter is required', code: 'MISSING_SYMBOLS' }, - }); - return; - } - - const symbolList = (symbols as string).split(','); - const watchlist = await marketService.getWatchlist(symbolList); - - res.json({ - success: true, - data: watchlist, - }); - } catch (error) { - next(error); - } -} - -// ============================================================================ -// Paper Trading Controllers -// ============================================================================ - -/** - * Initialize paper trading account (get or create) - */ -export async function initializePaperAccount(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const { initialBalance = 100000, name, currency } = req.body; - - const account = await paperTradingService.createAccount(userId, { - name, - initialBalance, - currency, - }); - - res.json({ - success: true, - data: account, - message: 'Paper trading account initialized', - }); - } catch (error) { - next(error); - } -} - -/** - * Get paper trading account (balances) - */ -export async function getPaperBalances(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const account = await paperTradingService.getOrCreateAccount(userId); - - res.json({ - success: true, - data: { - currentBalance: account.currentBalance, - initialBalance: account.initialBalance, - currency: account.currency, - totalPnl: account.totalPnl, - }, - }); - } catch (error) { - next(error); - } -} - -/** - * Create paper trading order (open position) - */ -export async function createPaperOrder(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const { symbol, side, quantity, price, stopLoss, takeProfit } = req.body; - - // Validate required fields - if (!symbol || !side || !quantity) { - res.status(400).json({ - success: false, - error: { message: 'Missing required fields: symbol, side, quantity', code: 'VALIDATION_ERROR' }, - }); - return; - } - - // Map side to direction - const direction: TradeDirection = side === 'buy' ? 'long' : 'short'; - - const position = await paperTradingService.openPosition(userId, { - symbol, - direction, - lotSize: quantity, - entryPrice: price, - stopLoss, - takeProfit, - }); - - res.status(201).json({ - success: true, - data: position, - }); - } catch (error) { - next(error); - } -} - -/** - * Cancel paper trading order (not applicable in new model, returns error) - */ -export async function cancelPaperOrder(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - // In the new PostgreSQL-based model, we don't have pending orders - positions are opened immediately - res.status(400).json({ - success: false, - error: { message: 'Order cancellation not supported. Use close position instead.', code: 'NOT_SUPPORTED' }, - }); - } catch (error) { - next(error); - } -} - -/** - * Get paper trading orders (returns open positions as "orders") - */ -export async function getPaperOrders(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const { symbol, limit } = req.query; - - // Return open positions as "orders" for backwards compatibility - const positions = await paperTradingService.getPositions(userId, { - status: 'open', - symbol: symbol as string | undefined, - limit: limit ? Number(limit) : undefined, - }); - - res.json({ - success: true, - data: positions, - }); - } catch (error) { - next(error); - } -} - -/** - * Get paper trading positions - */ -export async function getPaperPositions(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const { status, symbol } = req.query; - - const positions = await paperTradingService.getPositions(userId, { - status: status as PositionStatus | undefined, - symbol: symbol as string | undefined, - }); - - res.json({ - success: true, - data: positions, - }); - } catch (error) { - next(error); - } -} - -/** - * Close paper trading position - */ -export async function closePaperPosition(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const { positionId } = req.params; - const { exitPrice, closeReason } = req.body; - - const position = await paperTradingService.closePosition(positionId, userId, { - exitPrice, - closeReason, - }); - - if (!position) { - res.status(404).json({ - success: false, - error: { message: 'Position not found or already closed', code: 'NOT_FOUND' }, - }); - return; - } - - res.json({ - success: true, - data: position, - }); - } catch (error) { - next(error); - } -} - -/** - * Get paper trading trades history - */ -export async function getPaperTrades(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const { symbol, limit, startTime, endTime } = req.query; - - const trades = await paperTradingService.getTradeHistory(userId, { - symbol: symbol as string | undefined, - limit: limit ? Number(limit) : undefined, - startDate: startTime ? new Date(startTime as string) : undefined, - endDate: endTime ? new Date(endTime as string) : undefined, - }); - - res.json({ - success: true, - data: trades, - }); - } catch (error) { - next(error); - } -} - -/** - * Get paper trading portfolio summary - */ -export async function getPaperPortfolio(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const summary = await paperTradingService.getAccountSummary(userId); - - if (!summary) { - res.status(404).json({ - success: false, - error: { message: 'Account not found', code: 'NOT_FOUND' }, - }); - return; - } - - res.json({ - success: true, - data: { - totalEquity: summary.totalEquity, - totalCash: summary.account.currentBalance, - unrealizedPnl: summary.unrealizedPnl, - realizedPnl: summary.account.totalPnl, - todayPnl: summary.todayPnl, - todayPnlPercent: (summary.todayPnl / summary.totalEquity) * 100, - allTimePnl: summary.totalEquity - summary.account.initialBalance, - allTimePnlPercent: ((summary.totalEquity - summary.account.initialBalance) / summary.account.initialBalance) * 100, - openPositions: summary.openPositions, - winRate: summary.winRate, - }, - }); - } catch (error) { - next(error); - } -} - -/** - * Reset paper trading account - */ -export async function resetPaperAccount(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - // Get the user's account first - const account = await paperTradingService.getOrCreateAccount(userId); - const resetAccount = await paperTradingService.resetAccount(account.id, userId); - - if (!resetAccount) { - res.status(404).json({ - success: false, - error: { message: 'Account not found', code: 'NOT_FOUND' }, - }); - return; - } - - res.json({ - success: true, - data: resetAccount, - message: 'Paper trading account reset successfully', - }); - } catch (error) { - next(error); - } -} - -/** - * Get paper trading settings (returns account info) - */ -export async function getPaperSettings(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const account = await paperTradingService.getOrCreateAccount(userId); - - res.json({ - success: true, - data: { - initialBalance: account.initialBalance, - currency: account.currency, - name: account.name, - }, - }); - } catch (error) { - next(error); - } -} - -/** - * Update paper trading settings (limited - account name only) - */ -export async function updatePaperSettings(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - // Settings are now part of the account - to change settings, create a new account - res.status(400).json({ - success: false, - error: { message: 'Settings update not supported. Reset account or create a new one.', code: 'NOT_SUPPORTED' }, - }); - } catch (error) { - next(error); - } -} - -/** - * Get paper trading performance statistics - */ -export async function getPaperStats(req: AuthRequest, res: Response, next: NextFunction): Promise { - try { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return; - } - - const { accountId } = req.query; - - const stats = await paperTradingService.getPerformanceStats( - userId, - accountId as string | undefined - ); - - res.json({ - success: true, - data: stats, - }); - } catch (error) { - next(error); - } -} diff --git a/apps/backend/src/modules/trading/controllers/watchlist.controller.ts b/apps/backend/src/modules/trading/controllers/watchlist.controller.ts deleted file mode 100644 index 051bc4d..0000000 --- a/apps/backend/src/modules/trading/controllers/watchlist.controller.ts +++ /dev/null @@ -1,396 +0,0 @@ -/** - * Watchlist Controller - * Handles user watchlist CRUD operations - */ - -import { Request, Response, NextFunction } from 'express'; -import { - watchlistService, - CreateWatchlistInput, - UpdateWatchlistInput, - AddSymbolInput, - UpdateSymbolInput, -} from '../services/watchlist.service'; - -// ============================================================================ -// Types -// ============================================================================ - -// Use Request directly - user is already declared globally in auth.middleware.ts -type AuthRequest = Request; - -// ============================================================================ -// Helper -// ============================================================================ - -function getUserId(req: AuthRequest, res: Response): string | null { - const userId = req.user?.id; - if (!userId) { - res.status(401).json({ - success: false, - error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, - }); - return null; - } - return userId; -} - -// ============================================================================ -// Watchlist Controllers -// ============================================================================ - -/** - * Get all watchlists for the authenticated user - */ -export async function getUserWatchlists( - req: AuthRequest, - res: Response, - next: NextFunction -): Promise { - try { - const userId = getUserId(req, res); - if (!userId) return; - - const watchlists = await watchlistService.getUserWatchlists(userId); - - res.json({ - success: true, - data: watchlists, - }); - } catch (error) { - next(error); - } -} - -/** - * Get a single watchlist with items - */ -export async function getWatchlist( - req: AuthRequest, - res: Response, - next: NextFunction -): Promise { - try { - const userId = getUserId(req, res); - if (!userId) return; - - const { watchlistId } = req.params; - - const watchlist = await watchlistService.getWatchlist(watchlistId, userId); - - if (!watchlist) { - res.status(404).json({ - success: false, - error: { message: 'Watchlist not found', code: 'NOT_FOUND' }, - }); - return; - } - - res.json({ - success: true, - data: watchlist, - }); - } catch (error) { - next(error); - } -} - -/** - * Get default watchlist for user - */ -export async function getDefaultWatchlist( - req: AuthRequest, - res: Response, - next: NextFunction -): Promise { - try { - const userId = getUserId(req, res); - if (!userId) return; - - const watchlist = await watchlistService.getDefaultWatchlist(userId); - - if (!watchlist) { - res.status(404).json({ - success: false, - error: { message: 'No default watchlist found', code: 'NOT_FOUND' }, - }); - return; - } - - res.json({ - success: true, - data: watchlist, - }); - } catch (error) { - next(error); - } -} - -/** - * Create a new watchlist - */ -export async function createWatchlist( - req: AuthRequest, - res: Response, - next: NextFunction -): Promise { - try { - const userId = getUserId(req, res); - if (!userId) return; - - const input: CreateWatchlistInput = req.body; - - if (!input.name) { - res.status(400).json({ - success: false, - error: { message: 'Name is required', code: 'VALIDATION_ERROR' }, - }); - return; - } - - const watchlist = await watchlistService.createWatchlist(userId, input); - - res.status(201).json({ - success: true, - data: watchlist, - }); - } catch (error) { - next(error); - } -} - -/** - * Update a watchlist - */ -export async function updateWatchlist( - req: AuthRequest, - res: Response, - next: NextFunction -): Promise { - try { - const userId = getUserId(req, res); - if (!userId) return; - - const { watchlistId } = req.params; - const input: UpdateWatchlistInput = req.body; - - const watchlist = await watchlistService.updateWatchlist(watchlistId, userId, input); - - if (!watchlist) { - res.status(404).json({ - success: false, - error: { message: 'Watchlist not found', code: 'NOT_FOUND' }, - }); - return; - } - - res.json({ - success: true, - data: watchlist, - }); - } catch (error) { - next(error); - } -} - -/** - * Delete a watchlist - */ -export async function deleteWatchlist( - req: AuthRequest, - res: Response, - next: NextFunction -): Promise { - try { - const userId = getUserId(req, res); - if (!userId) return; - - const { watchlistId } = req.params; - - const deleted = await watchlistService.deleteWatchlist(watchlistId, userId); - - if (!deleted) { - res.status(404).json({ - success: false, - error: { message: 'Watchlist not found', code: 'NOT_FOUND' }, - }); - return; - } - - res.json({ - success: true, - message: 'Watchlist deleted successfully', - }); - } catch (error: unknown) { - if ((error as Error).message === 'Cannot delete default watchlist') { - res.status(400).json({ - success: false, - error: { message: 'Cannot delete default watchlist', code: 'CANNOT_DELETE_DEFAULT' }, - }); - return; - } - next(error); - } -} - -// ============================================================================ -// Watchlist Items Controllers -// ============================================================================ - -/** - * Add a symbol to a watchlist - */ -export async function addSymbol( - req: AuthRequest, - res: Response, - next: NextFunction -): Promise { - try { - const userId = getUserId(req, res); - if (!userId) return; - - const { watchlistId } = req.params; - const input: AddSymbolInput = req.body; - - if (!input.symbol) { - res.status(400).json({ - success: false, - error: { message: 'Symbol is required', code: 'VALIDATION_ERROR' }, - }); - return; - } - - const item = await watchlistService.addSymbol(watchlistId, userId, input); - - if (!item) { - res.status(404).json({ - success: false, - error: { message: 'Watchlist not found', code: 'NOT_FOUND' }, - }); - return; - } - - res.status(201).json({ - success: true, - data: item, - }); - } catch (error: unknown) { - if ((error as Error).message === 'Symbol already in watchlist') { - res.status(409).json({ - success: false, - error: { message: 'Symbol already in watchlist', code: 'DUPLICATE_SYMBOL' }, - }); - return; - } - next(error); - } -} - -/** - * Update a symbol in a watchlist - */ -export async function updateSymbol( - req: AuthRequest, - res: Response, - next: NextFunction -): Promise { - try { - const userId = getUserId(req, res); - if (!userId) return; - - const { watchlistId, symbol } = req.params; - const input: UpdateSymbolInput = req.body; - - const item = await watchlistService.updateSymbol(watchlistId, symbol, userId, input); - - if (!item) { - res.status(404).json({ - success: false, - error: { message: 'Symbol not found in watchlist', code: 'NOT_FOUND' }, - }); - return; - } - - res.json({ - success: true, - data: item, - }); - } catch (error) { - next(error); - } -} - -/** - * Remove a symbol from a watchlist - */ -export async function removeSymbol( - req: AuthRequest, - res: Response, - next: NextFunction -): Promise { - try { - const userId = getUserId(req, res); - if (!userId) return; - - const { watchlistId, symbol } = req.params; - - const removed = await watchlistService.removeSymbol(watchlistId, symbol, userId); - - if (!removed) { - res.status(404).json({ - success: false, - error: { message: 'Symbol not found in watchlist', code: 'NOT_FOUND' }, - }); - return; - } - - res.json({ - success: true, - message: 'Symbol removed from watchlist', - }); - } catch (error) { - next(error); - } -} - -/** - * Reorder symbols in a watchlist - */ -export async function reorderSymbols( - req: AuthRequest, - res: Response, - next: NextFunction -): Promise { - try { - const userId = getUserId(req, res); - if (!userId) return; - - const { watchlistId } = req.params; - const { symbolOrder } = req.body; - - if (!Array.isArray(symbolOrder)) { - res.status(400).json({ - success: false, - error: { message: 'symbolOrder must be an array', code: 'VALIDATION_ERROR' }, - }); - return; - } - - const success = await watchlistService.reorderSymbols(watchlistId, userId, symbolOrder); - - if (!success) { - res.status(404).json({ - success: false, - error: { message: 'Watchlist not found', code: 'NOT_FOUND' }, - }); - return; - } - - res.json({ - success: true, - message: 'Symbols reordered successfully', - }); - } catch (error) { - next(error); - } -} diff --git a/apps/backend/src/modules/trading/services/__tests__/alerts.service.spec.ts b/apps/backend/src/modules/trading/services/__tests__/alerts.service.spec.ts deleted file mode 100644 index 57dbe58..0000000 --- a/apps/backend/src/modules/trading/services/__tests__/alerts.service.spec.ts +++ /dev/null @@ -1,507 +0,0 @@ -/** - * Price Alerts Service Unit Tests - * - * Tests for price alerts service including: - * - Alert creation and management - * - Alert triggering logic - * - Notification preferences - * - Alert filtering - */ - -import { mockDb, createMockQueryResult, resetDatabaseMocks } from '../../../../__tests__/mocks/database.mock'; -import type { PriceAlert, AlertCondition } from '../alerts.service'; - -// Mock database -jest.mock('../../../../shared/database', () => ({ - db: mockDb, -})); - -// Mock logger -jest.mock('../../../../shared/utils/logger', () => ({ - logger: { - info: jest.fn(), - error: jest.fn(), - warn: jest.fn(), - debug: jest.fn(), - }, -})); - -// Import service after mocks -import { alertsService } from '../alerts.service'; - -describe('AlertsService', () => { - beforeEach(() => { - resetDatabaseMocks(); - }); - - describe('createAlert', () => { - it('should create a price alert with all options', async () => { - const mockAlert: PriceAlert = { - id: 'alert-123', - userId: 'user-123', - symbol: 'BTCUSDT', - condition: 'above', - price: 60000, - note: 'Bitcoin hitting resistance', - isActive: true, - notifyEmail: true, - notifyPush: true, - isRecurring: false, - createdAt: new Date(), - }; - - mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockAlert])); - - const result = await alertsService.createAlert({ - userId: 'user-123', - symbol: 'BTCUSDT', - condition: 'above', - price: 60000, - note: 'Bitcoin hitting resistance', - notifyEmail: true, - notifyPush: true, - }); - - expect(result).toEqual(mockAlert); - expect(mockDb.query).toHaveBeenCalledWith( - expect.stringContaining('INSERT INTO trading.price_alerts'), - expect.arrayContaining(['user-123', 'BTCUSDT', 'above', 60000]) - ); - }); - - it('should create alert with default notification settings', async () => { - const mockAlert: PriceAlert = { - id: 'alert-124', - userId: 'user-123', - symbol: 'ETHUSDT', - condition: 'below', - price: 2500, - isActive: true, - notifyEmail: false, - notifyPush: true, - isRecurring: false, - createdAt: new Date(), - }; - - mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockAlert])); - - const result = await alertsService.createAlert({ - userId: 'user-123', - symbol: 'ETHUSDT', - condition: 'below', - price: 2500, - }); - - expect(result.symbol).toBe('ETHUSDT'); - expect(result.condition).toBe('below'); - }); - - it('should normalize symbol to uppercase', async () => { - const mockAlert: PriceAlert = { - id: 'alert-125', - userId: 'user-123', - symbol: 'SOLUSDT', - condition: 'crosses_above', - price: 100, - isActive: true, - notifyEmail: true, - notifyPush: false, - isRecurring: false, - createdAt: new Date(), - }; - - mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockAlert])); - - const result = await alertsService.createAlert({ - userId: 'user-123', - symbol: 'solusdt', - condition: 'crosses_above', - price: 100, - notifyEmail: true, - }); - - expect(result.symbol).toBe('SOLUSDT'); - }); - - it('should handle database error during creation', async () => { - mockDb.query.mockRejectedValueOnce(new Error('Database error')); - - await expect( - alertsService.createAlert({ - userId: 'user-123', - symbol: 'BTCUSDT', - condition: 'above', - price: 60000, - }) - ).rejects.toThrow('Database error'); - }); - }); - - describe('getUserAlerts', () => { - it('should retrieve all alerts for a user', async () => { - const mockAlerts: PriceAlert[] = [ - { - id: 'alert-1', - userId: 'user-123', - symbol: 'BTCUSDT', - condition: 'above', - price: 60000, - isActive: true, - notifyEmail: true, - notifyPush: true, - isRecurring: false, - createdAt: new Date(), - }, - { - id: 'alert-2', - userId: 'user-123', - symbol: 'ETHUSDT', - condition: 'below', - price: 2500, - isActive: true, - notifyEmail: false, - notifyPush: true, - isRecurring: false, - createdAt: new Date(), - }, - ]; - - mockDb.query.mockResolvedValueOnce(createMockQueryResult(mockAlerts)); - - const result = await alertsService.getUserAlerts('user-123'); - - expect(result).toHaveLength(2); - expect(result[0].userId).toBe('user-123'); - expect(result[1].userId).toBe('user-123'); - }); - - it('should filter alerts by active status', async () => { - const mockAlerts: PriceAlert[] = [ - { - id: 'alert-1', - userId: 'user-123', - symbol: 'BTCUSDT', - condition: 'above', - price: 60000, - isActive: true, - notifyEmail: true, - notifyPush: true, - isRecurring: false, - createdAt: new Date(), - }, - ]; - - mockDb.query.mockResolvedValueOnce(createMockQueryResult(mockAlerts)); - - const result = await alertsService.getUserAlerts('user-123', { isActive: true }); - - expect(result).toHaveLength(1); - expect(result[0].isActive).toBe(true); - expect(mockDb.query).toHaveBeenCalledWith( - expect.stringContaining('is_active = $2'), - ['user-123', true] - ); - }); - - it('should filter alerts by symbol', async () => { - mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); - - await alertsService.getUserAlerts('user-123', { symbol: 'BTCUSDT' }); - - expect(mockDb.query).toHaveBeenCalledWith( - expect.stringContaining('symbol = $2'), - ['user-123', 'BTCUSDT'] - ); - }); - - it('should filter alerts by condition', async () => { - mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); - - await alertsService.getUserAlerts('user-123', { condition: 'above' }); - - expect(mockDb.query).toHaveBeenCalledWith( - expect.stringContaining('condition = $2'), - ['user-123', 'above'] - ); - }); - }); - - describe('getAlertById', () => { - it('should retrieve a specific alert', async () => { - const mockAlert: PriceAlert = { - id: 'alert-123', - userId: 'user-123', - symbol: 'BTCUSDT', - condition: 'above', - price: 60000, - note: 'Test note', - isActive: true, - notifyEmail: true, - notifyPush: false, - isRecurring: false, - createdAt: new Date(), - }; - - mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockAlert])); - - const result = await alertsService.getAlertById('alert-123'); - - expect(result).toEqual(mockAlert); - expect(mockDb.query).toHaveBeenCalledWith( - expect.stringContaining('SELECT * FROM trading.price_alerts'), - ['alert-123'] - ); - }); - - it('should return null for non-existent alert', async () => { - mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); - - const result = await alertsService.getAlertById('non-existent'); - - expect(result).toBeNull(); - }); - }); - - describe('updateAlert', () => { - it('should update alert price and note', async () => { - const mockUpdatedAlert: PriceAlert = { - id: 'alert-123', - userId: 'user-123', - symbol: 'BTCUSDT', - condition: 'above', - price: 65000, - note: 'Updated target', - isActive: true, - notifyEmail: true, - notifyPush: true, - isRecurring: false, - createdAt: new Date(), - }; - - mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockUpdatedAlert])); - - const result = await alertsService.updateAlert('alert-123', { - price: 65000, - note: 'Updated target', - }); - - expect(result.price).toBe(65000); - expect(result.note).toBe('Updated target'); - }); - - it('should update notification preferences', async () => { - const mockUpdatedAlert: PriceAlert = { - id: 'alert-123', - userId: 'user-123', - symbol: 'BTCUSDT', - condition: 'above', - price: 60000, - isActive: true, - notifyEmail: false, - notifyPush: true, - isRecurring: false, - createdAt: new Date(), - }; - - mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockUpdatedAlert])); - - const result = await alertsService.updateAlert('alert-123', { - notifyEmail: false, - notifyPush: true, - }); - - expect(result.notifyEmail).toBe(false); - expect(result.notifyPush).toBe(true); - }); - - it('should deactivate alert', async () => { - const mockUpdatedAlert: PriceAlert = { - id: 'alert-123', - userId: 'user-123', - symbol: 'BTCUSDT', - condition: 'above', - price: 60000, - isActive: false, - notifyEmail: true, - notifyPush: true, - isRecurring: false, - createdAt: new Date(), - }; - - mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockUpdatedAlert])); - - const result = await alertsService.updateAlert('alert-123', { isActive: false }); - - expect(result.isActive).toBe(false); - }); - }); - - describe('deleteAlert', () => { - it('should delete an alert', async () => { - mockDb.query.mockResolvedValueOnce(createMockQueryResult([{ id: 'alert-123' }])); - - await alertsService.deleteAlert('alert-123'); - - expect(mockDb.query).toHaveBeenCalledWith( - expect.stringContaining('DELETE FROM trading.price_alerts'), - ['alert-123'] - ); - }); - - it('should handle deletion of non-existent alert', async () => { - mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); - - await expect(alertsService.deleteAlert('non-existent')).rejects.toThrow(); - }); - }); - - describe('checkAlerts', () => { - it('should trigger alert when condition is met (above)', async () => { - const mockAlerts: PriceAlert[] = [ - { - id: 'alert-123', - userId: 'user-123', - symbol: 'BTCUSDT', - condition: 'above', - price: 60000, - isActive: true, - notifyEmail: true, - notifyPush: true, - isRecurring: false, - createdAt: new Date(), - }, - ]; - - mockDb.query.mockResolvedValueOnce(createMockQueryResult(mockAlerts)); - mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); - - const triggeredAlerts = await alertsService.checkAlerts('BTCUSDT', 61000); - - expect(triggeredAlerts).toHaveLength(1); - expect(triggeredAlerts[0].id).toBe('alert-123'); - expect(mockDb.query).toHaveBeenCalledWith( - expect.stringContaining('UPDATE trading.price_alerts'), - expect.arrayContaining([61000]) - ); - }); - - it('should trigger alert when condition is met (below)', async () => { - const mockAlerts: PriceAlert[] = [ - { - id: 'alert-124', - userId: 'user-123', - symbol: 'ETHUSDT', - condition: 'below', - price: 2500, - isActive: true, - notifyEmail: true, - notifyPush: false, - isRecurring: false, - createdAt: new Date(), - }, - ]; - - mockDb.query.mockResolvedValueOnce(createMockQueryResult(mockAlerts)); - mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); - - const triggeredAlerts = await alertsService.checkAlerts('ETHUSDT', 2400); - - expect(triggeredAlerts).toHaveLength(1); - expect(triggeredAlerts[0].condition).toBe('below'); - }); - - it('should not trigger alert when condition is not met', async () => { - const mockAlerts: PriceAlert[] = [ - { - id: 'alert-123', - userId: 'user-123', - symbol: 'BTCUSDT', - condition: 'above', - price: 60000, - isActive: true, - notifyEmail: true, - notifyPush: true, - isRecurring: false, - createdAt: new Date(), - }, - ]; - - mockDb.query.mockResolvedValueOnce(createMockQueryResult(mockAlerts)); - - const triggeredAlerts = await alertsService.checkAlerts('BTCUSDT', 59000); - - expect(triggeredAlerts).toHaveLength(0); - }); - - it('should reactivate recurring alert after trigger', async () => { - const mockAlerts: PriceAlert[] = [ - { - id: 'alert-125', - userId: 'user-123', - symbol: 'SOLUSDT', - condition: 'above', - price: 100, - isActive: true, - notifyEmail: true, - notifyPush: true, - isRecurring: true, - createdAt: new Date(), - }, - ]; - - mockDb.query.mockResolvedValueOnce(createMockQueryResult(mockAlerts)); - mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); - - await alertsService.checkAlerts('SOLUSDT', 105); - - expect(mockDb.query).toHaveBeenCalledWith( - expect.stringContaining('UPDATE trading.price_alerts'), - expect.arrayContaining([105]) - ); - }); - }); - - describe('getTriggeredAlerts', () => { - it('should retrieve triggered alerts for a user', async () => { - const now = new Date(); - const mockTriggeredAlerts: PriceAlert[] = [ - { - id: 'alert-1', - userId: 'user-123', - symbol: 'BTCUSDT', - condition: 'above', - price: 60000, - isActive: false, - triggeredAt: now, - triggeredPrice: 61000, - notifyEmail: true, - notifyPush: true, - isRecurring: false, - createdAt: new Date(), - }, - ]; - - mockDb.query.mockResolvedValueOnce(createMockQueryResult(mockTriggeredAlerts)); - - const result = await alertsService.getTriggeredAlerts('user-123', { limit: 10 }); - - expect(result).toHaveLength(1); - expect(result[0].triggeredAt).toBeDefined(); - expect(result[0].triggeredPrice).toBe(61000); - }); - - it('should filter triggered alerts by date range', async () => { - mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); - - const startDate = new Date('2024-01-01'); - const endDate = new Date('2024-12-31'); - - await alertsService.getTriggeredAlerts('user-123', { startDate, endDate }); - - expect(mockDb.query).toHaveBeenCalledWith( - expect.stringContaining('triggered_at BETWEEN'), - expect.arrayContaining(['user-123', startDate, endDate]) - ); - }); - }); -}); diff --git a/apps/backend/src/modules/trading/services/__tests__/paper-trading.service.spec.ts b/apps/backend/src/modules/trading/services/__tests__/paper-trading.service.spec.ts deleted file mode 100644 index f59afff..0000000 --- a/apps/backend/src/modules/trading/services/__tests__/paper-trading.service.spec.ts +++ /dev/null @@ -1,473 +0,0 @@ -/** - * Paper Trading Service Unit Tests - * - * Tests for paper trading service including: - * - Account creation and management - * - Position opening and closing - * - P&L calculations - * - Account statistics - */ - -import { mockDb, createMockQueryResult, resetDatabaseMocks } from '../../../../__tests__/mocks/database.mock'; -import type { PaperAccount, PaperPosition } from '../paper-trading.service'; - -// Mock database -jest.mock('../../../../shared/database', () => ({ - db: mockDb, -})); - -// Mock logger -jest.mock('../../../../shared/utils/logger', () => ({ - logger: { - info: jest.fn(), - error: jest.fn(), - warn: jest.fn(), - debug: jest.fn(), - }, -})); - -// Mock market service -const mockGetPrice = jest.fn(); -jest.mock('../market.service', () => ({ - marketService: { - getPrice: mockGetPrice, - }, -})); - -// Import service after mocks -import { paperTradingService } from '../paper-trading.service'; - -describe('PaperTradingService', () => { - beforeEach(() => { - resetDatabaseMocks(); - mockGetPrice.mockReset(); - }); - - describe('createAccount', () => { - it('should create a new paper trading account with default values', async () => { - const mockAccount: PaperAccount = { - id: 'account-123', - userId: 'user-123', - name: 'My Trading Account', - initialBalance: 10000, - currentBalance: 10000, - currency: 'USD', - totalTrades: 0, - winningTrades: 0, - totalPnl: 0, - maxDrawdown: 0, - isActive: true, - createdAt: new Date(), - updatedAt: new Date(), - }; - - mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockAccount])); - - const result = await paperTradingService.createAccount('user-123', { - name: 'My Trading Account', - }); - - expect(result).toEqual(mockAccount); - expect(mockDb.query).toHaveBeenCalledWith( - expect.stringContaining('INSERT INTO trading.paper_trading_accounts'), - expect.arrayContaining(['user-123', 'My Trading Account']) - ); - }); - - it('should create account with custom initial balance', async () => { - const mockAccount: PaperAccount = { - id: 'account-123', - userId: 'user-123', - name: 'High Stakes Account', - initialBalance: 100000, - currentBalance: 100000, - currency: 'USD', - totalTrades: 0, - winningTrades: 0, - totalPnl: 0, - maxDrawdown: 0, - isActive: true, - createdAt: new Date(), - updatedAt: new Date(), - }; - - mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockAccount])); - - const result = await paperTradingService.createAccount('user-123', { - name: 'High Stakes Account', - initialBalance: 100000, - }); - - expect(result.initialBalance).toBe(100000); - expect(result.currentBalance).toBe(100000); - }); - - it('should handle database error during account creation', async () => { - mockDb.query.mockRejectedValueOnce(new Error('Database connection failed')); - - await expect( - paperTradingService.createAccount('user-123', { name: 'Test Account' }) - ).rejects.toThrow('Database connection failed'); - }); - }); - - describe('getAccount', () => { - it('should retrieve an existing account', async () => { - const mockAccount: PaperAccount = { - id: 'account-123', - userId: 'user-123', - name: 'My Account', - initialBalance: 10000, - currentBalance: 12500, - currency: 'USD', - totalTrades: 25, - winningTrades: 18, - totalPnl: 2500, - maxDrawdown: -500, - isActive: true, - createdAt: new Date(), - updatedAt: new Date(), - }; - - mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockAccount])); - - const result = await paperTradingService.getAccount('account-123'); - - expect(result).toEqual(mockAccount); - expect(mockDb.query).toHaveBeenCalledWith( - expect.stringContaining('SELECT * FROM trading.paper_trading_accounts'), - ['account-123'] - ); - }); - - it('should return null for non-existent account', async () => { - mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); - - const result = await paperTradingService.getAccount('non-existent'); - - expect(result).toBeNull(); - }); - }); - - describe('openPosition', () => { - it('should open a long position with market price', async () => { - mockGetPrice.mockResolvedValueOnce(50000); - - const mockPosition: PaperPosition = { - id: 'position-123', - accountId: 'account-123', - userId: 'user-123', - symbol: 'BTCUSDT', - direction: 'long', - lotSize: 0.1, - entryPrice: 50000, - stopLoss: 49000, - takeProfit: 52000, - status: 'open', - openedAt: new Date(), - createdAt: new Date(), - updatedAt: new Date(), - }; - - mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockPosition])); - mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); - - const result = await paperTradingService.openPosition('account-123', 'user-123', { - symbol: 'BTCUSDT', - direction: 'long', - lotSize: 0.1, - stopLoss: 49000, - takeProfit: 52000, - }); - - expect(result).toEqual(mockPosition); - expect(mockGetPrice).toHaveBeenCalledWith('BTCUSDT'); - expect(result.entryPrice).toBe(50000); - }); - - it('should open a short position with specified price', async () => { - const mockPosition: PaperPosition = { - id: 'position-124', - accountId: 'account-123', - userId: 'user-123', - symbol: 'ETHUSDT', - direction: 'short', - lotSize: 1.0, - entryPrice: 3000, - stopLoss: 3100, - takeProfit: 2850, - status: 'open', - openedAt: new Date(), - createdAt: new Date(), - updatedAt: new Date(), - }; - - mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockPosition])); - mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); - - const result = await paperTradingService.openPosition('account-123', 'user-123', { - symbol: 'ETHUSDT', - direction: 'short', - lotSize: 1.0, - entryPrice: 3000, - stopLoss: 3100, - takeProfit: 2850, - }); - - expect(result.direction).toBe('short'); - expect(result.entryPrice).toBe(3000); - expect(mockGetPrice).not.toHaveBeenCalled(); - }); - - it('should handle insufficient balance error', async () => { - mockGetPrice.mockResolvedValueOnce(50000); - mockDb.query.mockRejectedValueOnce( - new Error('Insufficient balance for position') - ); - - await expect( - paperTradingService.openPosition('account-123', 'user-123', { - symbol: 'BTCUSDT', - direction: 'long', - lotSize: 100, - }) - ).rejects.toThrow('Insufficient balance for position'); - }); - }); - - describe('closePosition', () => { - it('should close position with profit at market price', async () => { - mockGetPrice.mockResolvedValueOnce(52000); - - const mockClosedPosition: PaperPosition = { - id: 'position-123', - accountId: 'account-123', - userId: 'user-123', - symbol: 'BTCUSDT', - direction: 'long', - lotSize: 0.1, - entryPrice: 50000, - exitPrice: 52000, - status: 'closed', - openedAt: new Date(Date.now() - 3600000), - closedAt: new Date(), - closeReason: 'Manual close', - realizedPnl: 200, - createdAt: new Date(), - updatedAt: new Date(), - }; - - mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockClosedPosition])); - mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); - - const result = await paperTradingService.closePosition( - 'position-123', - 'account-123', - { closeReason: 'Manual close' } - ); - - expect(result.status).toBe('closed'); - expect(result.exitPrice).toBe(52000); - expect(result.realizedPnl).toBe(200); - expect(mockGetPrice).toHaveBeenCalled(); - }); - - it('should close position with loss at specified price', async () => { - const mockClosedPosition: PaperPosition = { - id: 'position-124', - accountId: 'account-123', - userId: 'user-123', - symbol: 'ETHUSDT', - direction: 'long', - lotSize: 1.0, - entryPrice: 3000, - exitPrice: 2900, - status: 'closed', - openedAt: new Date(Date.now() - 3600000), - closedAt: new Date(), - closeReason: 'Stop loss triggered', - realizedPnl: -100, - createdAt: new Date(), - updatedAt: new Date(), - }; - - mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockClosedPosition])); - mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); - - const result = await paperTradingService.closePosition( - 'position-124', - 'account-123', - { exitPrice: 2900, closeReason: 'Stop loss triggered' } - ); - - expect(result.exitPrice).toBe(2900); - expect(result.realizedPnl).toBe(-100); - expect(result.closeReason).toBe('Stop loss triggered'); - }); - - it('should handle position not found error', async () => { - mockDb.query.mockRejectedValueOnce(new Error('Position not found')); - - await expect( - paperTradingService.closePosition('invalid-id', 'account-123', {}) - ).rejects.toThrow('Position not found'); - }); - }); - - describe('getOpenPositions', () => { - it('should retrieve all open positions for an account', async () => { - const mockPositions: PaperPosition[] = [ - { - id: 'pos-1', - accountId: 'account-123', - userId: 'user-123', - symbol: 'BTCUSDT', - direction: 'long', - lotSize: 0.1, - entryPrice: 50000, - status: 'open', - openedAt: new Date(), - createdAt: new Date(), - updatedAt: new Date(), - }, - { - id: 'pos-2', - accountId: 'account-123', - userId: 'user-123', - symbol: 'ETHUSDT', - direction: 'short', - lotSize: 1.0, - entryPrice: 3000, - status: 'open', - openedAt: new Date(), - createdAt: new Date(), - updatedAt: new Date(), - }, - ]; - - mockDb.query.mockResolvedValueOnce(createMockQueryResult(mockPositions)); - - const result = await paperTradingService.getOpenPositions('account-123'); - - expect(result).toHaveLength(2); - expect(result[0].status).toBe('open'); - expect(result[1].status).toBe('open'); - }); - - it('should return empty array when no open positions', async () => { - mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); - - const result = await paperTradingService.getOpenPositions('account-123'); - - expect(result).toEqual([]); - }); - }); - - describe('getAccountSummary', () => { - it('should calculate account summary with open positions', async () => { - const mockAccount: PaperAccount = { - id: 'account-123', - userId: 'user-123', - name: 'Test Account', - initialBalance: 10000, - currentBalance: 11500, - currency: 'USD', - totalTrades: 10, - winningTrades: 7, - totalPnl: 1500, - maxDrawdown: -300, - isActive: true, - createdAt: new Date(), - updatedAt: new Date(), - }; - - mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockAccount])); - mockDb.query.mockResolvedValueOnce(createMockQueryResult([{ count: 3 }])); - mockDb.query.mockResolvedValueOnce(createMockQueryResult([{ unrealized_pnl: 500 }])); - mockDb.query.mockResolvedValueOnce(createMockQueryResult([{ today_pnl: 150 }])); - - const result = await paperTradingService.getAccountSummary('account-123'); - - expect(result.account).toEqual(mockAccount); - expect(result.openPositions).toBe(3); - expect(result.unrealizedPnl).toBe(500); - expect(result.todayPnl).toBe(150); - expect(result.winRate).toBe(70); - expect(result.totalEquity).toBe(12000); - }); - - it('should handle account with no positions', async () => { - const mockAccount: PaperAccount = { - id: 'account-123', - userId: 'user-123', - name: 'Empty Account', - initialBalance: 10000, - currentBalance: 10000, - currency: 'USD', - totalTrades: 0, - winningTrades: 0, - totalPnl: 0, - maxDrawdown: 0, - isActive: true, - createdAt: new Date(), - updatedAt: new Date(), - }; - - mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockAccount])); - mockDb.query.mockResolvedValueOnce(createMockQueryResult([{ count: 0 }])); - mockDb.query.mockResolvedValueOnce(createMockQueryResult([{ unrealized_pnl: 0 }])); - mockDb.query.mockResolvedValueOnce(createMockQueryResult([{ today_pnl: 0 }])); - - const result = await paperTradingService.getAccountSummary('account-123'); - - expect(result.openPositions).toBe(0); - expect(result.winRate).toBe(0); - expect(result.totalEquity).toBe(10000); - }); - }); - - describe('getPositionHistory', () => { - it('should retrieve closed positions history', async () => { - const mockHistory: PaperPosition[] = [ - { - id: 'pos-1', - accountId: 'account-123', - userId: 'user-123', - symbol: 'BTCUSDT', - direction: 'long', - lotSize: 0.1, - entryPrice: 50000, - exitPrice: 51000, - status: 'closed', - openedAt: new Date(Date.now() - 86400000), - closedAt: new Date(Date.now() - 43200000), - realizedPnl: 100, - createdAt: new Date(), - updatedAt: new Date(), - }, - ]; - - mockDb.query.mockResolvedValueOnce(createMockQueryResult(mockHistory)); - - const result = await paperTradingService.getPositionHistory('account-123', { limit: 10 }); - - expect(result).toHaveLength(1); - expect(result[0].status).toBe('closed'); - expect(result[0].realizedPnl).toBe(100); - }); - - it('should filter history by symbol', async () => { - mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); - - await paperTradingService.getPositionHistory('account-123', { - symbol: 'ETHUSDT', - limit: 10, - }); - - expect(mockDb.query).toHaveBeenCalledWith( - expect.stringContaining('symbol = $2'), - expect.arrayContaining(['account-123', 'ETHUSDT']) - ); - }); - }); -}); diff --git a/apps/backend/src/modules/trading/services/__tests__/watchlist.service.spec.ts b/apps/backend/src/modules/trading/services/__tests__/watchlist.service.spec.ts deleted file mode 100644 index db89df3..0000000 --- a/apps/backend/src/modules/trading/services/__tests__/watchlist.service.spec.ts +++ /dev/null @@ -1,372 +0,0 @@ -/** - * Watchlist Service Unit Tests - * - * Tests for watchlist service including: - * - Watchlist creation and management - * - Symbol addition and removal - * - Watchlist ordering and favorites - */ - -import { mockDb, createMockQueryResult, resetDatabaseMocks } from '../../../../__tests__/mocks/database.mock'; - -// Mock database -jest.mock('../../../../shared/database', () => ({ - db: mockDb, -})); - -// Mock logger -jest.mock('../../../../shared/utils/logger', () => ({ - logger: { - info: jest.fn(), - error: jest.fn(), - warn: jest.fn(), - debug: jest.fn(), - }, -})); - -// Import service after mocks -import { watchlistService } from '../watchlist.service'; - -describe('WatchlistService', () => { - beforeEach(() => { - resetDatabaseMocks(); - }); - - describe('createWatchlist', () => { - it('should create a new watchlist', async () => { - const mockWatchlist = { - id: 'watchlist-123', - user_id: 'user-123', - name: 'My Favorites', - description: 'Top crypto picks', - is_default: false, - sort_order: 1, - created_at: new Date(), - updated_at: new Date(), - }; - - mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockWatchlist])); - - const result = await watchlistService.createWatchlist('user-123', { - name: 'My Favorites', - description: 'Top crypto picks', - }); - - expect(result.name).toBe('My Favorites'); - expect(mockDb.query).toHaveBeenCalledWith( - expect.stringContaining('INSERT INTO trading.watchlists'), - expect.arrayContaining(['user-123', 'My Favorites']) - ); - }); - - it('should create default watchlist', async () => { - const mockWatchlist = { - id: 'watchlist-124', - user_id: 'user-123', - name: 'Default', - is_default: true, - sort_order: 0, - created_at: new Date(), - updated_at: new Date(), - }; - - mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockWatchlist])); - - const result = await watchlistService.createWatchlist('user-123', { - name: 'Default', - isDefault: true, - }); - - expect(result.isDefault).toBe(true); - }); - - it('should handle duplicate watchlist name', async () => { - mockDb.query.mockRejectedValueOnce(new Error('Watchlist name already exists')); - - await expect( - watchlistService.createWatchlist('user-123', { name: 'Duplicate' }) - ).rejects.toThrow('Watchlist name already exists'); - }); - }); - - describe('getUserWatchlists', () => { - it('should retrieve all watchlists for a user', async () => { - const mockWatchlists = [ - { - id: 'watchlist-1', - user_id: 'user-123', - name: 'Default', - is_default: true, - sort_order: 0, - created_at: new Date(), - updated_at: new Date(), - }, - { - id: 'watchlist-2', - user_id: 'user-123', - name: 'Altcoins', - is_default: false, - sort_order: 1, - created_at: new Date(), - updated_at: new Date(), - }, - ]; - - mockDb.query.mockResolvedValueOnce(createMockQueryResult(mockWatchlists)); - - const result = await watchlistService.getUserWatchlists('user-123'); - - expect(result).toHaveLength(2); - expect(result[0].isDefault).toBe(true); - expect(result[1].name).toBe('Altcoins'); - }); - - it('should return empty array when user has no watchlists', async () => { - mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); - - const result = await watchlistService.getUserWatchlists('user-123'); - - expect(result).toEqual([]); - }); - }); - - describe('addSymbol', () => { - it('should add symbol to watchlist', async () => { - const mockItem = { - id: 'item-123', - watchlist_id: 'watchlist-123', - symbol: 'BTCUSDT', - sort_order: 1, - created_at: new Date(), - }; - - mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockItem])); - - const result = await watchlistService.addSymbol('watchlist-123', 'BTCUSDT'); - - expect(result.symbol).toBe('BTCUSDT'); - expect(mockDb.query).toHaveBeenCalledWith( - expect.stringContaining('INSERT INTO trading.watchlist_items'), - expect.arrayContaining(['watchlist-123', 'BTCUSDT']) - ); - }); - - it('should normalize symbol to uppercase', async () => { - const mockItem = { - id: 'item-124', - watchlist_id: 'watchlist-123', - symbol: 'ETHUSDT', - sort_order: 2, - created_at: new Date(), - }; - - mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockItem])); - - const result = await watchlistService.addSymbol('watchlist-123', 'ethusdt'); - - expect(result.symbol).toBe('ETHUSDT'); - }); - - it('should handle duplicate symbol', async () => { - mockDb.query.mockRejectedValueOnce(new Error('Symbol already in watchlist')); - - await expect( - watchlistService.addSymbol('watchlist-123', 'BTCUSDT') - ).rejects.toThrow('Symbol already in watchlist'); - }); - }); - - describe('removeSymbol', () => { - it('should remove symbol from watchlist', async () => { - mockDb.query.mockResolvedValueOnce(createMockQueryResult([{ id: 'item-123' }])); - - await watchlistService.removeSymbol('watchlist-123', 'BTCUSDT'); - - expect(mockDb.query).toHaveBeenCalledWith( - expect.stringContaining('DELETE FROM trading.watchlist_items'), - expect.arrayContaining(['watchlist-123', 'BTCUSDT']) - ); - }); - - it('should handle removing non-existent symbol', async () => { - mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); - - await expect( - watchlistService.removeSymbol('watchlist-123', 'INVALID') - ).rejects.toThrow(); - }); - }); - - describe('getWatchlistSymbols', () => { - it('should retrieve all symbols in a watchlist', async () => { - const mockItems = [ - { - id: 'item-1', - watchlist_id: 'watchlist-123', - symbol: 'BTCUSDT', - sort_order: 1, - created_at: new Date(), - }, - { - id: 'item-2', - watchlist_id: 'watchlist-123', - symbol: 'ETHUSDT', - sort_order: 2, - created_at: new Date(), - }, - { - id: 'item-3', - watchlist_id: 'watchlist-123', - symbol: 'SOLUSDT', - sort_order: 3, - created_at: new Date(), - }, - ]; - - mockDb.query.mockResolvedValueOnce(createMockQueryResult(mockItems)); - - const result = await watchlistService.getWatchlistSymbols('watchlist-123'); - - expect(result).toHaveLength(3); - expect(result[0].symbol).toBe('BTCUSDT'); - expect(result[1].symbol).toBe('ETHUSDT'); - expect(result[2].symbol).toBe('SOLUSDT'); - }); - - it('should return symbols in sort order', async () => { - const mockItems = [ - { - id: 'item-1', - watchlist_id: 'watchlist-123', - symbol: 'BTCUSDT', - sort_order: 1, - created_at: new Date(), - }, - ]; - - mockDb.query.mockResolvedValueOnce(createMockQueryResult(mockItems)); - - await watchlistService.getWatchlistSymbols('watchlist-123'); - - expect(mockDb.query).toHaveBeenCalledWith( - expect.stringContaining('ORDER BY sort_order'), - ['watchlist-123'] - ); - }); - }); - - describe('updateWatchlist', () => { - it('should update watchlist name and description', async () => { - const mockUpdatedWatchlist = { - id: 'watchlist-123', - user_id: 'user-123', - name: 'Updated Name', - description: 'Updated description', - is_default: false, - sort_order: 1, - created_at: new Date(), - updated_at: new Date(), - }; - - mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockUpdatedWatchlist])); - - const result = await watchlistService.updateWatchlist('watchlist-123', { - name: 'Updated Name', - description: 'Updated description', - }); - - expect(result.name).toBe('Updated Name'); - expect(result.description).toBe('Updated description'); - }); - - it('should update sort order', async () => { - const mockUpdatedWatchlist = { - id: 'watchlist-123', - user_id: 'user-123', - name: 'Test', - is_default: false, - sort_order: 5, - created_at: new Date(), - updated_at: new Date(), - }; - - mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockUpdatedWatchlist])); - - const result = await watchlistService.updateWatchlist('watchlist-123', { - sortOrder: 5, - }); - - expect(result.sortOrder).toBe(5); - }); - }); - - describe('deleteWatchlist', () => { - it('should delete a watchlist', async () => { - mockDb.query.mockResolvedValueOnce(createMockQueryResult([{ id: 'watchlist-123' }])); - - await watchlistService.deleteWatchlist('watchlist-123'); - - expect(mockDb.query).toHaveBeenCalledWith( - expect.stringContaining('DELETE FROM trading.watchlists'), - ['watchlist-123'] - ); - }); - - it('should prevent deletion of default watchlist', async () => { - const mockDefaultWatchlist = { - id: 'watchlist-123', - user_id: 'user-123', - name: 'Default', - is_default: true, - created_at: new Date(), - updated_at: new Date(), - }; - - mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockDefaultWatchlist])); - - await expect(watchlistService.deleteWatchlist('watchlist-123')).rejects.toThrow( - 'Cannot delete default watchlist' - ); - }); - - it('should cascade delete watchlist items', async () => { - mockDb.query.mockResolvedValueOnce(createMockQueryResult([{ id: 'watchlist-123' }])); - mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); - - await watchlistService.deleteWatchlist('watchlist-123'); - - expect(mockDb.query).toHaveBeenCalledTimes(2); - }); - }); - - describe('reorderSymbols', () => { - it('should reorder symbols in watchlist', async () => { - const symbolOrder = ['ETHUSDT', 'BTCUSDT', 'SOLUSDT']; - - mockDb.query.mockResolvedValue(createMockQueryResult([])); - - await watchlistService.reorderSymbols('watchlist-123', symbolOrder); - - expect(mockDb.query).toHaveBeenCalledTimes(3); - }); - - it('should assign correct sort order to each symbol', async () => { - const symbolOrder = ['BTCUSDT', 'ETHUSDT']; - - mockDb.query.mockResolvedValue(createMockQueryResult([])); - - await watchlistService.reorderSymbols('watchlist-123', symbolOrder); - - expect(mockDb.query).toHaveBeenNthCalledWith( - 1, - expect.stringContaining('UPDATE trading.watchlist_items'), - expect.arrayContaining(['BTCUSDT', 1]) - ); - expect(mockDb.query).toHaveBeenNthCalledWith( - 2, - expect.stringContaining('UPDATE trading.watchlist_items'), - expect.arrayContaining(['ETHUSDT', 2]) - ); - }); - }); -}); diff --git a/apps/backend/src/modules/trading/services/alerts.service.ts b/apps/backend/src/modules/trading/services/alerts.service.ts deleted file mode 100644 index 05bca10..0000000 --- a/apps/backend/src/modules/trading/services/alerts.service.ts +++ /dev/null @@ -1,332 +0,0 @@ -/** - * Price Alerts Service - * Manages price alerts and notifications using PostgreSQL trading.price_alerts table - */ - -import { db } from '../../../shared/database'; -import { logger } from '../../../shared/utils/logger'; - -// ============================================================================ -// Types (matching trading.price_alerts schema) -// ============================================================================ - -export type AlertCondition = 'above' | 'below' | 'crosses_above' | 'crosses_below'; - -export interface PriceAlert { - id: string; - userId: string; - symbol: string; - condition: AlertCondition; - price: number; - note?: string; - isActive: boolean; - triggeredAt?: Date; - triggeredPrice?: number; - notifyEmail: boolean; - notifyPush: boolean; - isRecurring: boolean; - createdAt: Date; -} - -export interface CreateAlertInput { - userId: string; - symbol: string; - condition: AlertCondition; - price: number; - note?: string; - notifyEmail?: boolean; - notifyPush?: boolean; - isRecurring?: boolean; -} - -export interface UpdateAlertInput { - price?: number; - note?: string; - notifyEmail?: boolean; - notifyPush?: boolean; - isRecurring?: boolean; - isActive?: boolean; -} - -export interface AlertsFilter { - isActive?: boolean; - symbol?: string; - condition?: AlertCondition; -} - -// ============================================================================ -// Helper Functions -// ============================================================================ - -function transformAlert(row: Record): PriceAlert { - return { - id: row.id as string, - userId: row.user_id as string, - symbol: row.symbol as string, - condition: row.condition as AlertCondition, - price: parseFloat(row.price as string), - note: row.note as string | undefined, - isActive: row.is_active as boolean, - triggeredAt: row.triggered_at ? new Date(row.triggered_at as string) : undefined, - triggeredPrice: row.triggered_price ? parseFloat(row.triggered_price as string) : undefined, - notifyEmail: row.notify_email as boolean, - notifyPush: row.notify_push as boolean, - isRecurring: row.is_recurring as boolean, - createdAt: new Date(row.created_at as string), - }; -} - -// ============================================================================ -// Alerts Service Class -// ============================================================================ - -class AlertsService { - // ========================================================================== - // CRUD Operations - // ========================================================================== - - async createAlert(input: CreateAlertInput): Promise { - const result = await db.query>( - `INSERT INTO trading.price_alerts ( - user_id, symbol, condition, price, note, - notify_email, notify_push, is_recurring - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - RETURNING *`, - [ - input.userId, - input.symbol.toUpperCase(), - input.condition, - input.price, - input.note, - input.notifyEmail ?? true, - input.notifyPush ?? true, - input.isRecurring ?? false, - ] - ); - - logger.info('[AlertsService] Alert created:', { - userId: input.userId, - symbol: input.symbol, - condition: input.condition, - price: input.price, - }); - - return transformAlert(result.rows[0]); - } - - async getAlertById(id: string): Promise { - const result = await db.query>( - `SELECT * FROM trading.price_alerts WHERE id = $1`, - [id] - ); - if (result.rows.length === 0) return null; - return transformAlert(result.rows[0]); - } - - async getUserAlerts(userId: string, filter: AlertsFilter = {}): Promise { - const conditions: string[] = ['user_id = $1']; - const params: (string | boolean)[] = [userId]; - let paramIndex = 2; - - if (filter.isActive !== undefined) { - conditions.push(`is_active = $${paramIndex++}`); - params.push(filter.isActive); - } - if (filter.symbol) { - conditions.push(`symbol = $${paramIndex++}`); - params.push(filter.symbol.toUpperCase()); - } - if (filter.condition) { - conditions.push(`condition = $${paramIndex++}`); - params.push(filter.condition); - } - - const result = await db.query>( - `SELECT * FROM trading.price_alerts - WHERE ${conditions.join(' AND ')} - ORDER BY created_at DESC`, - params - ); - - return result.rows.map(transformAlert); - } - - async updateAlert(id: string, userId: string, updates: UpdateAlertInput): Promise { - const fields: string[] = []; - const params: (string | number | boolean | null)[] = []; - let paramIndex = 1; - - if (updates.price !== undefined) { - fields.push(`price = $${paramIndex++}`); - params.push(updates.price); - } - if (updates.note !== undefined) { - fields.push(`note = $${paramIndex++}`); - params.push(updates.note); - } - if (updates.notifyEmail !== undefined) { - fields.push(`notify_email = $${paramIndex++}`); - params.push(updates.notifyEmail); - } - if (updates.notifyPush !== undefined) { - fields.push(`notify_push = $${paramIndex++}`); - params.push(updates.notifyPush); - } - if (updates.isRecurring !== undefined) { - fields.push(`is_recurring = $${paramIndex++}`); - params.push(updates.isRecurring); - } - if (updates.isActive !== undefined) { - fields.push(`is_active = $${paramIndex++}`); - params.push(updates.isActive); - } - - if (fields.length === 0) return null; - - params.push(id, userId); - const result = await db.query>( - `UPDATE trading.price_alerts - SET ${fields.join(', ')} - WHERE id = $${paramIndex++} AND user_id = $${paramIndex} - RETURNING *`, - params - ); - - if (result.rows.length === 0) return null; - return transformAlert(result.rows[0]); - } - - async deleteAlert(id: string, userId: string): Promise { - const result = await db.query( - `DELETE FROM trading.price_alerts WHERE id = $1 AND user_id = $2`, - [id, userId] - ); - return (result.rowCount ?? 0) > 0; - } - - async disableAlert(id: string, userId: string): Promise { - return this.updateAlert(id, userId, { isActive: false }); - } - - async enableAlert(id: string, userId: string): Promise { - return this.updateAlert(id, userId, { isActive: true }); - } - - // ========================================================================== - // Alert Checking - // ========================================================================== - - async checkAlerts(symbol: string, currentPrice: number, previousPrice?: number): Promise { - // Get all active alerts for this symbol - const result = await db.query>( - `SELECT * FROM trading.price_alerts - WHERE symbol = $1 AND is_active = TRUE`, - [symbol.toUpperCase()] - ); - - const alerts = result.rows.map(transformAlert); - const triggeredAlerts: PriceAlert[] = []; - - for (const alert of alerts) { - let shouldTrigger = false; - - switch (alert.condition) { - case 'above': - shouldTrigger = currentPrice >= alert.price; - break; - case 'below': - shouldTrigger = currentPrice <= alert.price; - break; - case 'crosses_above': - if (previousPrice !== undefined) { - shouldTrigger = previousPrice < alert.price && currentPrice >= alert.price; - } - break; - case 'crosses_below': - if (previousPrice !== undefined) { - shouldTrigger = previousPrice > alert.price && currentPrice <= alert.price; - } - break; - } - - if (shouldTrigger) { - await this.triggerAlert(alert.id, currentPrice); - alert.triggeredAt = new Date(); - alert.triggeredPrice = currentPrice; - alert.isActive = !alert.isRecurring; - triggeredAlerts.push(alert); - } - } - - return triggeredAlerts; - } - - async triggerAlert(id: string, currentPrice: number): Promise { - // Get alert to check if recurring - const alert = await this.getAlertById(id); - if (!alert) return; - - await db.query( - `UPDATE trading.price_alerts - SET triggered_at = CURRENT_TIMESTAMP, - triggered_price = $1, - is_active = $2 - WHERE id = $3`, - [currentPrice, alert.isRecurring, id] - ); - - logger.info('[AlertsService] Alert triggered:', { alertId: id, currentPrice }); - - // TODO: Send notifications based on notify_email and notify_push - // This would integrate with email and push notification services - } - - // ========================================================================== - // Statistics - // ========================================================================== - - async getUserAlertStats(userId: string): Promise<{ - total: number; - active: number; - triggered: number; - }> { - const result = await db.query>( - `SELECT - COUNT(*) as total, - COUNT(*) FILTER (WHERE is_active = TRUE) as active, - COUNT(*) FILTER (WHERE triggered_at IS NOT NULL) as triggered - FROM trading.price_alerts - WHERE user_id = $1`, - [userId] - ); - - const stats = result.rows[0]; - return { - total: parseInt(stats.total, 10), - active: parseInt(stats.active, 10), - triggered: parseInt(stats.triggered, 10), - }; - } - - async getActiveAlertsForSymbols(symbols: string[]): Promise> { - if (symbols.length === 0) return new Map(); - - const result = await db.query>( - `SELECT * FROM trading.price_alerts - WHERE symbol = ANY($1) AND is_active = TRUE`, - [symbols.map((s) => s.toUpperCase())] - ); - - const alertsMap = new Map(); - for (const row of result.rows) { - const alert = transformAlert(row); - const existing = alertsMap.get(alert.symbol) || []; - existing.push(alert); - alertsMap.set(alert.symbol, existing); - } - - return alertsMap; - } -} - -export const alertsService = new AlertsService(); diff --git a/apps/backend/src/modules/trading/services/binance.service.ts b/apps/backend/src/modules/trading/services/binance.service.ts deleted file mode 100644 index 557794a..0000000 --- a/apps/backend/src/modules/trading/services/binance.service.ts +++ /dev/null @@ -1,542 +0,0 @@ -/** - * Binance Service - * Integrates with Binance API for real-time market data - */ - -import axios, { AxiosInstance, AxiosError } from 'axios'; -import { EventEmitter } from 'events'; -import WebSocket from 'ws'; - -// ============================================================================ -// Types -// ============================================================================ - -export interface Kline { - openTime: number; - open: string; - high: string; - low: string; - close: string; - volume: string; - closeTime: number; - quoteVolume: string; - trades: number; - takerBuyBaseVolume: string; - takerBuyQuoteVolume: string; -} - -export interface Ticker24h { - symbol: string; - priceChange: string; - priceChangePercent: string; - weightedAvgPrice: string; - prevClosePrice: string; - lastPrice: string; - lastQty: string; - bidPrice: string; - bidQty: string; - askPrice: string; - askQty: string; - openPrice: string; - highPrice: string; - lowPrice: string; - volume: string; - quoteVolume: string; - openTime: number; - closeTime: number; - firstId: number; - lastId: number; - count: number; -} - -export interface OrderBookEntry { - price: string; - quantity: string; -} - -export interface OrderBook { - lastUpdateId: number; - bids: OrderBookEntry[]; - asks: OrderBookEntry[]; -} - -export interface ExchangeInfo { - timezone: string; - serverTime: number; - symbols: SymbolInfo[]; -} - -export interface SymbolInfo { - symbol: string; - status: string; - baseAsset: string; - baseAssetPrecision: number; - quoteAsset: string; - quotePrecision: number; - quoteAssetPrecision: number; - filters: SymbolFilter[]; -} - -export interface SymbolFilter { - filterType: string; - minPrice?: string; - maxPrice?: string; - tickSize?: string; - minQty?: string; - maxQty?: string; - stepSize?: string; - minNotional?: string; -} - -// WebSocket message types -interface WsKlineMessage { - s: string; - k: { - t: number; - T: number; - o: string; - h: string; - l: string; - c: string; - v: string; - q: string; - n: number; - V: string; - Q: string; - x: boolean; - }; -} - -interface WsTradeMessage { - s: string; - t: number; - p: string; - q: string; - T: number; - m: boolean; -} - -interface WsDepthMessage { - lastUpdateId: number; - bids: [string, string][]; - asks: [string, string][]; -} - -export type Interval = - | '1m' - | '3m' - | '5m' - | '15m' - | '30m' - | '1h' - | '2h' - | '4h' - | '6h' - | '8h' - | '12h' - | '1d' - | '3d' - | '1w' - | '1M'; - -// ============================================================================ -// Rate Limiter -// ============================================================================ - -class RateLimiter { - private requests: number[] = []; - private readonly limit: number; - private readonly window: number; // in milliseconds - - constructor(limit: number = 1200, windowSeconds: number = 60) { - this.limit = limit; - this.window = windowSeconds * 1000; - } - - async acquire(): Promise { - const now = Date.now(); - - // Remove old requests outside the window - this.requests = this.requests.filter((time) => now - time < this.window); - - if (this.requests.length >= this.limit) { - const oldestRequest = this.requests[0]; - const waitTime = this.window - (now - oldestRequest); - await new Promise((resolve) => setTimeout(resolve, waitTime)); - } - - this.requests.push(Date.now()); - } - - getRemaining(): number { - const now = Date.now(); - this.requests = this.requests.filter((time) => now - time < this.window); - return this.limit - this.requests.length; - } -} - -// ============================================================================ -// Binance Service -// ============================================================================ - -export class BinanceService extends EventEmitter { - private client: AxiosInstance; - private rateLimiter: RateLimiter; - private wsConnections: Map = new Map(); - private reconnectAttempts: Map = new Map(); - private readonly maxReconnectAttempts = 5; - private readonly baseUrl = 'https://api.binance.com'; - private readonly wsBaseUrl = 'wss://stream.binance.com:9443/ws'; - - constructor() { - super(); - - this.client = axios.create({ - baseURL: this.baseUrl, - timeout: 10000, - headers: { - 'Content-Type': 'application/json', - }, - }); - - this.rateLimiter = new RateLimiter(1200, 60); - - // Add request interceptor for rate limiting - this.client.interceptors.request.use(async (config) => { - await this.rateLimiter.acquire(); - return config; - }); - - // Add response interceptor for error handling - this.client.interceptors.response.use( - (response) => response, - (error: AxiosError) => { - if (error.response?.status === 429) { - console.error('[Binance] Rate limit exceeded'); - this.emit('rateLimitExceeded'); - } - throw error; - } - ); - } - - // ========================================================================== - // REST API Methods - // ========================================================================== - - /** - * Get server time - */ - async getServerTime(): Promise { - const response = await this.client.get('/api/v3/time'); - return response.data.serverTime; - } - - /** - * Get exchange info - */ - async getExchangeInfo(symbols?: string[]): Promise { - const params: Record = {}; - if (symbols && symbols.length > 0) { - params.symbols = JSON.stringify(symbols); - } - const response = await this.client.get('/api/v3/exchangeInfo', { params }); - return response.data; - } - - /** - * Get klines/candlestick data - */ - async getKlines( - symbol: string, - interval: Interval, - options: { - startTime?: number; - endTime?: number; - limit?: number; - } = {} - ): Promise { - const { startTime, endTime, limit = 500 } = options; - - const params: Record = { - symbol: symbol.toUpperCase(), - interval, - limit: Math.min(limit, 1000), - }; - - if (startTime) params.startTime = startTime; - if (endTime) params.endTime = endTime; - - const response = await this.client.get('/api/v3/klines', { params }); - - return response.data.map((k: (string | number)[]): Kline => ({ - openTime: k[0] as number, - open: k[1] as string, - high: k[2] as string, - low: k[3] as string, - close: k[4] as string, - volume: k[5] as string, - closeTime: k[6] as number, - quoteVolume: k[7] as string, - trades: k[8] as number, - takerBuyBaseVolume: k[9] as string, - takerBuyQuoteVolume: k[10] as string, - })); - } - - /** - * Get 24hr ticker - */ - async get24hrTicker(symbol?: string): Promise { - const params: Record = {}; - if (symbol) params.symbol = symbol.toUpperCase(); - - const response = await this.client.get('/api/v3/ticker/24hr', { params }); - return response.data; - } - - /** - * Get current price - */ - async getPrice(symbol?: string): Promise<{ symbol: string; price: string } | { symbol: string; price: string }[]> { - const params: Record = {}; - if (symbol) params.symbol = symbol.toUpperCase(); - - const response = await this.client.get('/api/v3/ticker/price', { params }); - return response.data; - } - - /** - * Get order book - */ - async getOrderBook(symbol: string, limit: number = 100): Promise { - const response = await this.client.get('/api/v3/depth', { - params: { - symbol: symbol.toUpperCase(), - limit: Math.min(limit, 5000), - }, - }); - - return { - lastUpdateId: response.data.lastUpdateId, - bids: response.data.bids.map(([price, quantity]: [string, string]) => ({ - price, - quantity, - })), - asks: response.data.asks.map(([price, quantity]: [string, string]) => ({ - price, - quantity, - })), - }; - } - - /** - * Get recent trades - */ - async getRecentTrades( - symbol: string, - limit: number = 500 - ): Promise< - { - id: number; - price: string; - qty: string; - quoteQty: string; - time: number; - isBuyerMaker: boolean; - }[] - > { - const response = await this.client.get('/api/v3/trades', { - params: { - symbol: symbol.toUpperCase(), - limit: Math.min(limit, 1000), - }, - }); - return response.data; - } - - // ========================================================================== - // WebSocket Methods - // ========================================================================== - - /** - * Subscribe to kline stream - */ - subscribeKlines(symbol: string, interval: Interval): void { - const streamName = `${symbol.toLowerCase()}@kline_${interval}`; - this.createWebSocket(streamName, (rawData) => { - const data = rawData as WsKlineMessage; - if (data.k) { - const kline: Kline = { - openTime: data.k.t, - open: data.k.o, - high: data.k.h, - low: data.k.l, - close: data.k.c, - volume: data.k.v, - closeTime: data.k.T, - quoteVolume: data.k.q, - trades: data.k.n, - takerBuyBaseVolume: data.k.V, - takerBuyQuoteVolume: data.k.Q, - }; - this.emit('kline', { symbol: data.s, interval, kline, isFinal: data.k.x }); - } - }); - } - - /** - * Subscribe to ticker stream - */ - subscribeTicker(symbol: string): void { - const streamName = `${symbol.toLowerCase()}@ticker`; - this.createWebSocket(streamName, (data) => { - this.emit('ticker', data); - }); - } - - /** - * Subscribe to mini ticker (all symbols) - */ - subscribeAllMiniTickers(): void { - const streamName = '!miniTicker@arr'; - this.createWebSocket(streamName, (data) => { - this.emit('allMiniTickers', data); - }); - } - - /** - * Subscribe to trade stream - */ - subscribeTrades(symbol: string): void { - const streamName = `${symbol.toLowerCase()}@trade`; - this.createWebSocket(streamName, (rawData) => { - const data = rawData as WsTradeMessage; - this.emit('trade', { - symbol: data.s, - tradeId: data.t, - price: data.p, - quantity: data.q, - time: data.T, - isBuyerMaker: data.m, - }); - }); - } - - /** - * Subscribe to order book depth stream - */ - subscribeDepth(symbol: string, levels: 5 | 10 | 20 = 10): void { - const streamName = `${symbol.toLowerCase()}@depth${levels}@100ms`; - this.createWebSocket(streamName, (rawData) => { - const data = rawData as WsDepthMessage; - this.emit('depth', { - symbol: symbol.toUpperCase(), - lastUpdateId: data.lastUpdateId, - bids: data.bids, - asks: data.asks, - }); - }); - } - - /** - * Unsubscribe from stream - */ - unsubscribe(streamName: string): void { - const ws = this.wsConnections.get(streamName); - if (ws) { - ws.close(); - this.wsConnections.delete(streamName); - this.reconnectAttempts.delete(streamName); - } - } - - /** - * Unsubscribe from all streams - */ - unsubscribeAll(): void { - for (const [streamName] of this.wsConnections) { - this.unsubscribe(streamName); - } - } - - // ========================================================================== - // Private Methods - // ========================================================================== - - private createWebSocket(streamName: string, onMessage: (data: unknown) => void): void { - if (this.wsConnections.has(streamName)) { - return; - } - - const url = `${this.wsBaseUrl}/${streamName}`; - const ws = new WebSocket(url); - - ws.on('open', () => { - console.log(`[Binance WS] Connected to ${streamName}`); - this.reconnectAttempts.set(streamName, 0); - this.emit('wsConnected', streamName); - }); - - ws.on('message', (data: WebSocket.Data) => { - try { - const parsed = JSON.parse(data.toString()); - onMessage(parsed); - } catch (error) { - console.error('[Binance WS] Failed to parse message:', error); - } - }); - - ws.on('close', () => { - console.log(`[Binance WS] Disconnected from ${streamName}`); - this.wsConnections.delete(streamName); - this.emit('wsDisconnected', streamName); - this.attemptReconnect(streamName, onMessage); - }); - - ws.on('error', (error: Error) => { - console.error(`[Binance WS] Error on ${streamName}:`, error.message); - this.emit('wsError', { streamName, error }); - }); - - this.wsConnections.set(streamName, ws); - } - - private attemptReconnect(streamName: string, onMessage: (data: unknown) => void): void { - const attempts = this.reconnectAttempts.get(streamName) || 0; - - if (attempts >= this.maxReconnectAttempts) { - console.error(`[Binance WS] Max reconnect attempts reached for ${streamName}`); - this.emit('wsMaxReconnectReached', streamName); - return; - } - - const delay = Math.min(1000 * Math.pow(2, attempts), 30000); - console.log(`[Binance WS] Reconnecting to ${streamName} in ${delay}ms (attempt ${attempts + 1})`); - - setTimeout(() => { - this.reconnectAttempts.set(streamName, attempts + 1); - this.createWebSocket(streamName, onMessage); - }, delay); - } - - // ========================================================================== - // Utility Methods - // ========================================================================== - - getRemainingRequests(): number { - return this.rateLimiter.getRemaining(); - } - - getActiveStreams(): string[] { - return Array.from(this.wsConnections.keys()); - } - - isStreamActive(streamName: string): boolean { - const ws = this.wsConnections.get(streamName); - return ws !== undefined && ws.readyState === WebSocket.OPEN; - } -} - -// Export singleton instance -export const binanceService = new BinanceService(); diff --git a/apps/backend/src/modules/trading/services/cache.service.ts b/apps/backend/src/modules/trading/services/cache.service.ts deleted file mode 100644 index fb54208..0000000 --- a/apps/backend/src/modules/trading/services/cache.service.ts +++ /dev/null @@ -1,260 +0,0 @@ -/** - * Cache Service - * In-memory cache with TTL for market data - */ - -// ============================================================================ -// Types -// ============================================================================ - -interface CacheEntry { - data: T; - expiresAt: number; -} - -interface CacheStats { - hits: number; - misses: number; - size: number; - hitRate: number; -} - -// ============================================================================ -// Cache Service -// ============================================================================ - -export class CacheService { - private cache: Map> = new Map(); - private hits: number = 0; - private misses: number = 0; - private cleanupInterval: NodeJS.Timeout | null = null; - private readonly defaultTTL: number; // in milliseconds - - constructor(defaultTTLSeconds: number = 60) { - this.defaultTTL = defaultTTLSeconds * 1000; - this.startCleanupInterval(); - } - - /** - * Get a value from cache - */ - get(key: string): T | null { - const entry = this.cache.get(key) as CacheEntry | undefined; - - if (!entry) { - this.misses++; - return null; - } - - if (Date.now() > entry.expiresAt) { - this.cache.delete(key); - this.misses++; - return null; - } - - this.hits++; - return entry.data; - } - - /** - * Set a value in cache - */ - set(key: string, data: T, ttlSeconds?: number): void { - const ttl = ttlSeconds ? ttlSeconds * 1000 : this.defaultTTL; - this.cache.set(key, { - data, - expiresAt: Date.now() + ttl, - }); - } - - /** - * Get or set a value in cache - */ - async getOrSet(key: string, fetcher: () => Promise, ttlSeconds?: number): Promise { - const cached = this.get(key); - if (cached !== null) { - return cached; - } - - const data = await fetcher(); - this.set(key, data, ttlSeconds); - return data; - } - - /** - * Delete a value from cache - */ - delete(key: string): boolean { - return this.cache.delete(key); - } - - /** - * Delete all values matching a pattern - */ - deletePattern(pattern: string): number { - const regex = new RegExp(pattern.replace(/\*/g, '.*')); - let count = 0; - - for (const key of this.cache.keys()) { - if (regex.test(key)) { - this.cache.delete(key); - count++; - } - } - - return count; - } - - /** - * Clear the entire cache - */ - clear(): void { - this.cache.clear(); - this.hits = 0; - this.misses = 0; - } - - /** - * Check if a key exists and is not expired - */ - has(key: string): boolean { - const entry = this.cache.get(key); - if (!entry) return false; - if (Date.now() > entry.expiresAt) { - this.cache.delete(key); - return false; - } - return true; - } - - /** - * Get cache statistics - */ - getStats(): CacheStats { - const total = this.hits + this.misses; - return { - hits: this.hits, - misses: this.misses, - size: this.cache.size, - hitRate: total > 0 ? this.hits / total : 0, - }; - } - - /** - * Get all keys in cache - */ - keys(): string[] { - return Array.from(this.cache.keys()); - } - - /** - * Get cache size - */ - size(): number { - return this.cache.size; - } - - /** - * Refresh TTL for a key - */ - touch(key: string, ttlSeconds?: number): boolean { - const entry = this.cache.get(key); - if (!entry) return false; - - const ttl = ttlSeconds ? ttlSeconds * 1000 : this.defaultTTL; - entry.expiresAt = Date.now() + ttl; - return true; - } - - /** - * Get time to live for a key in seconds - */ - ttl(key: string): number | null { - const entry = this.cache.get(key); - if (!entry) return null; - - const remaining = entry.expiresAt - Date.now(); - return remaining > 0 ? Math.ceil(remaining / 1000) : 0; - } - - // ========================================================================== - // Private Methods - // ========================================================================== - - private startCleanupInterval(): void { - // Cleanup expired entries every minute - this.cleanupInterval = setInterval(() => { - this.cleanup(); - }, 60000); - } - - private cleanup(): void { - const now = Date.now(); - let cleaned = 0; - - for (const [key, entry] of this.cache.entries()) { - if (now > entry.expiresAt) { - this.cache.delete(key); - cleaned++; - } - } - - if (cleaned > 0) { - console.log(`[Cache] Cleaned up ${cleaned} expired entries`); - } - } - - /** - * Stop cleanup interval (for graceful shutdown) - */ - destroy(): void { - if (this.cleanupInterval) { - clearInterval(this.cleanupInterval); - this.cleanupInterval = null; - } - this.clear(); - } -} - -// ============================================================================ -// Specialized Cache Classes -// ============================================================================ - -/** - * Market Data Cache - optimized for high-frequency market data - */ -export class MarketDataCache extends CacheService { - constructor() { - // Default TTL of 5 seconds for market data - super(5); - } - - // Cache key generators - static klineKey(symbol: string, interval: string): string { - return `kline:${symbol}:${interval}`; - } - - static tickerKey(symbol: string): string { - return `ticker:${symbol}`; - } - - static priceKey(symbol: string): string { - return `price:${symbol}`; - } - - static orderBookKey(symbol: string): string { - return `orderbook:${symbol}`; - } - - static exchangeInfoKey(): string { - return 'exchange:info'; - } - - static symbolInfoKey(symbol: string): string { - return `symbol:info:${symbol}`; - } -} - -// Export singleton instances -export const cacheService = new CacheService(60); -export const marketDataCache = new MarketDataCache(); diff --git a/apps/backend/src/modules/trading/services/indicators.service.ts b/apps/backend/src/modules/trading/services/indicators.service.ts deleted file mode 100644 index 3995e6f..0000000 --- a/apps/backend/src/modules/trading/services/indicators.service.ts +++ /dev/null @@ -1,538 +0,0 @@ -/** - * Technical Indicators Service - * Calculates common technical analysis indicators - */ - -import { marketService } from './market.service'; -import type { Interval } from './binance.service'; - -// ============================================================================ -// Types -// ============================================================================ - -export interface OHLCV { - time: number; - open: number; - high: number; - low: number; - close: number; - volume: number; -} - -export interface SMAResult { - time: number; - value: number; -} - -export interface EMAResult { - time: number; - value: number; -} - -export interface RSIResult { - time: number; - value: number; -} - -export interface MACDResult { - time: number; - macd: number; - signal: number; - histogram: number; -} - -export interface StochasticResult { - time: number; - k: number; - d: number; -} - -export interface BollingerBandsResult { - time: number; - upper: number; - middle: number; - lower: number; - bandwidth: number; -} - -export interface ATRResult { - time: number; - value: number; -} - -export interface VWAPResult { - time: number; - value: number; -} - -export interface IchimokuResult { - time: number; - tenkanSen: number; - kijunSen: number; - senkouSpanA: number; - senkouSpanB: number; - chikouSpan: number; -} - -export interface IndicatorParams { - symbol: string; - interval: Interval; - period?: number; - limit?: number; -} - -export interface MACDParams extends IndicatorParams { - fastPeriod?: number; - slowPeriod?: number; - signalPeriod?: number; -} - -export interface BollingerParams extends IndicatorParams { - stdDev?: number; -} - -export interface StochasticParams extends IndicatorParams { - kPeriod?: number; - dPeriod?: number; - smoothK?: number; -} - -// ============================================================================ -// Indicators Service -// ============================================================================ - -class IndicatorsService { - /** - * Convert klines to OHLCV format - * CandlestickData already has numeric values - */ - private klinesToOHLCV(klines: unknown[]): OHLCV[] { - return (klines as Record[]).map((k) => ({ - time: k.time as number, - open: k.open as number, - high: k.high as number, - low: k.low as number, - close: k.close as number, - volume: k.volume as number, - })); - } - - /** - * Fetch OHLCV data for calculations - */ - private async getOHLCV(symbol: string, interval: Interval, limit: number): Promise { - const klines = await marketService.getKlines(symbol, interval, { limit }); - return this.klinesToOHLCV(klines); - } - - // ========================================================================== - // Moving Averages - // ========================================================================== - - /** - * Calculate Simple Moving Average (SMA) - */ - async getSMA(params: IndicatorParams): Promise { - const { symbol, interval, period = 20, limit = 100 } = params; - const ohlcv = await this.getOHLCV(symbol, interval, limit + period); - return this.calculateSMA(ohlcv, period); - } - - private calculateSMA(data: OHLCV[], period: number): SMAResult[] { - const result: SMAResult[] = []; - for (let i = period - 1; i < data.length; i++) { - let sum = 0; - for (let j = 0; j < period; j++) { - sum += data[i - j].close; - } - result.push({ - time: data[i].time, - value: sum / period, - }); - } - return result; - } - - /** - * Calculate Exponential Moving Average (EMA) - */ - async getEMA(params: IndicatorParams): Promise { - const { symbol, interval, period = 20, limit = 100 } = params; - const ohlcv = await this.getOHLCV(symbol, interval, limit + period); - return this.calculateEMA(ohlcv, period); - } - - private calculateEMA(data: OHLCV[], period: number): EMAResult[] { - const result: EMAResult[] = []; - const multiplier = 2 / (period + 1); - - // Start with SMA for first EMA value - let sum = 0; - for (let i = 0; i < period; i++) { - sum += data[i].close; - } - let ema = sum / period; - result.push({ time: data[period - 1].time, value: ema }); - - // Calculate EMA for remaining data - for (let i = period; i < data.length; i++) { - ema = (data[i].close - ema) * multiplier + ema; - result.push({ time: data[i].time, value: ema }); - } - - return result; - } - - // ========================================================================== - // Oscillators - // ========================================================================== - - /** - * Calculate Relative Strength Index (RSI) - */ - async getRSI(params: IndicatorParams): Promise { - const { symbol, interval, period = 14, limit = 100 } = params; - const ohlcv = await this.getOHLCV(symbol, interval, limit + period + 1); - return this.calculateRSI(ohlcv, period); - } - - private calculateRSI(data: OHLCV[], period: number): RSIResult[] { - const result: RSIResult[] = []; - const changes: number[] = []; - - // Calculate price changes - for (let i = 1; i < data.length; i++) { - changes.push(data[i].close - data[i - 1].close); - } - - // Calculate initial average gains and losses - let avgGain = 0; - let avgLoss = 0; - for (let i = 0; i < period; i++) { - if (changes[i] > 0) { - avgGain += changes[i]; - } else { - avgLoss += Math.abs(changes[i]); - } - } - avgGain /= period; - avgLoss /= period; - - // First RSI value - let rs = avgLoss === 0 ? 100 : avgGain / avgLoss; - let rsi = 100 - 100 / (1 + rs); - result.push({ time: data[period].time, value: rsi }); - - // Calculate RSI for remaining data using Wilder's smoothing - for (let i = period; i < changes.length; i++) { - const change = changes[i]; - const gain = change > 0 ? change : 0; - const loss = change < 0 ? Math.abs(change) : 0; - - avgGain = (avgGain * (period - 1) + gain) / period; - avgLoss = (avgLoss * (period - 1) + loss) / period; - - rs = avgLoss === 0 ? 100 : avgGain / avgLoss; - rsi = 100 - 100 / (1 + rs); - result.push({ time: data[i + 1].time, value: rsi }); - } - - return result; - } - - /** - * Calculate MACD (Moving Average Convergence Divergence) - */ - async getMACD(params: MACDParams): Promise { - const { - symbol, - interval, - fastPeriod = 12, - slowPeriod = 26, - signalPeriod = 9, - limit = 100, - } = params; - - const ohlcv = await this.getOHLCV(symbol, interval, limit + slowPeriod + signalPeriod); - return this.calculateMACD(ohlcv, fastPeriod, slowPeriod, signalPeriod); - } - - private calculateMACD( - data: OHLCV[], - fastPeriod: number, - slowPeriod: number, - signalPeriod: number - ): MACDResult[] { - const result: MACDResult[] = []; - - // Calculate EMAs - const fastEMA = this.calculateEMAFromClose(data.map((d) => d.close), fastPeriod); - const slowEMA = this.calculateEMAFromClose(data.map((d) => d.close), slowPeriod); - - // Calculate MACD line - const macdLine: number[] = []; - const startIndex = slowPeriod - 1; - for (let i = startIndex; i < data.length; i++) { - const fastIndex = i - (slowPeriod - fastPeriod); - if (fastIndex >= 0 && fastIndex < fastEMA.length) { - macdLine.push(fastEMA[fastIndex] - slowEMA[i - startIndex]); - } - } - - // Calculate signal line (EMA of MACD) - const signalLine = this.calculateEMAFromClose(macdLine, signalPeriod); - - // Build result - for (let i = signalPeriod - 1; i < macdLine.length; i++) { - const dataIndex = startIndex + i; - const macd = macdLine[i]; - const signal = signalLine[i - (signalPeriod - 1)]; - result.push({ - time: data[dataIndex].time, - macd, - signal, - histogram: macd - signal, - }); - } - - return result; - } - - private calculateEMAFromClose(closes: number[], period: number): number[] { - const result: number[] = []; - const multiplier = 2 / (period + 1); - - let sum = 0; - for (let i = 0; i < period; i++) { - sum += closes[i]; - } - let ema = sum / period; - result.push(ema); - - for (let i = period; i < closes.length; i++) { - ema = (closes[i] - ema) * multiplier + ema; - result.push(ema); - } - - return result; - } - - /** - * Calculate Stochastic Oscillator - */ - async getStochastic(params: StochasticParams): Promise { - const { symbol, interval, kPeriod = 14, dPeriod = 3, smoothK = 3, limit = 100 } = params; - const ohlcv = await this.getOHLCV(symbol, interval, limit + kPeriod + smoothK + dPeriod); - return this.calculateStochastic(ohlcv, kPeriod, dPeriod, smoothK); - } - - private calculateStochastic( - data: OHLCV[], - kPeriod: number, - dPeriod: number, - smoothK: number - ): StochasticResult[] { - const result: StochasticResult[] = []; - const rawK: number[] = []; - - // Calculate raw %K - for (let i = kPeriod - 1; i < data.length; i++) { - let highest = -Infinity; - let lowest = Infinity; - for (let j = 0; j < kPeriod; j++) { - highest = Math.max(highest, data[i - j].high); - lowest = Math.min(lowest, data[i - j].low); - } - const k = highest === lowest ? 50 : ((data[i].close - lowest) / (highest - lowest)) * 100; - rawK.push(k); - } - - // Smooth %K - const smoothedK = this.calculateSMAFromValues(rawK, smoothK); - - // Calculate %D (SMA of smoothed %K) - const percentD = this.calculateSMAFromValues(smoothedK, dPeriod); - - // Build result - const resultStart = kPeriod + smoothK + dPeriod - 3; - for (let i = 0; i < percentD.length; i++) { - const dataIndex = resultStart + i; - result.push({ - time: data[dataIndex].time, - k: smoothedK[i + dPeriod - 1], - d: percentD[i], - }); - } - - return result; - } - - private calculateSMAFromValues(values: number[], period: number): number[] { - const result: number[] = []; - for (let i = period - 1; i < values.length; i++) { - let sum = 0; - for (let j = 0; j < period; j++) { - sum += values[i - j]; - } - result.push(sum / period); - } - return result; - } - - // ========================================================================== - // Volatility Indicators - // ========================================================================== - - /** - * Calculate Bollinger Bands - */ - async getBollingerBands(params: BollingerParams): Promise { - const { symbol, interval, period = 20, stdDev = 2, limit = 100 } = params; - const ohlcv = await this.getOHLCV(symbol, interval, limit + period); - return this.calculateBollingerBands(ohlcv, period, stdDev); - } - - private calculateBollingerBands(data: OHLCV[], period: number, stdDev: number): BollingerBandsResult[] { - const result: BollingerBandsResult[] = []; - - for (let i = period - 1; i < data.length; i++) { - const closes: number[] = []; - for (let j = 0; j < period; j++) { - closes.push(data[i - j].close); - } - - // Calculate SMA (middle band) - const middle = closes.reduce((a, b) => a + b, 0) / period; - - // Calculate standard deviation - const squaredDiffs = closes.map((c) => Math.pow(c - middle, 2)); - const variance = squaredDiffs.reduce((a, b) => a + b, 0) / period; - const sd = Math.sqrt(variance); - - // Calculate bands - const upper = middle + stdDev * sd; - const lower = middle - stdDev * sd; - const bandwidth = ((upper - lower) / middle) * 100; - - result.push({ - time: data[i].time, - upper, - middle, - lower, - bandwidth, - }); - } - - return result; - } - - /** - * Calculate Average True Range (ATR) - */ - async getATR(params: IndicatorParams): Promise { - const { symbol, interval, period = 14, limit = 100 } = params; - const ohlcv = await this.getOHLCV(symbol, interval, limit + period + 1); - return this.calculateATR(ohlcv, period); - } - - private calculateATR(data: OHLCV[], period: number): ATRResult[] { - const result: ATRResult[] = []; - const trueRanges: number[] = []; - - // Calculate True Range - for (let i = 1; i < data.length; i++) { - const high = data[i].high; - const low = data[i].low; - const prevClose = data[i - 1].close; - - const tr = Math.max(high - low, Math.abs(high - prevClose), Math.abs(low - prevClose)); - trueRanges.push(tr); - } - - // Calculate initial ATR (simple average) - let atr = 0; - for (let i = 0; i < period; i++) { - atr += trueRanges[i]; - } - atr /= period; - result.push({ time: data[period].time, value: atr }); - - // Calculate ATR using Wilder's smoothing - for (let i = period; i < trueRanges.length; i++) { - atr = (atr * (period - 1) + trueRanges[i]) / period; - result.push({ time: data[i + 1].time, value: atr }); - } - - return result; - } - - // ========================================================================== - // Volume Indicators - // ========================================================================== - - /** - * Calculate Volume Weighted Average Price (VWAP) - */ - async getVWAP(params: IndicatorParams): Promise { - const { symbol, interval, limit = 100 } = params; - const ohlcv = await this.getOHLCV(symbol, interval, limit); - return this.calculateVWAP(ohlcv); - } - - private calculateVWAP(data: OHLCV[]): VWAPResult[] { - const result: VWAPResult[] = []; - let cumulativeTPV = 0; - let cumulativeVolume = 0; - - for (const candle of data) { - const typicalPrice = (candle.high + candle.low + candle.close) / 3; - cumulativeTPV += typicalPrice * candle.volume; - cumulativeVolume += candle.volume; - - const vwap = cumulativeVolume > 0 ? cumulativeTPV / cumulativeVolume : typicalPrice; - result.push({ time: candle.time, value: vwap }); - } - - return result; - } - - // ========================================================================== - // All-in-One - // ========================================================================== - - /** - * Get multiple indicators for a symbol - */ - async getAllIndicators( - symbol: string, - interval: Interval, - limit: number = 100 - ): Promise<{ - sma20: SMAResult[]; - sma50: SMAResult[]; - ema12: EMAResult[]; - ema26: EMAResult[]; - rsi: RSIResult[]; - macd: MACDResult[]; - bollinger: BollingerBandsResult[]; - atr: ATRResult[]; - }> { - // Fetch all data needed (max period is 50 for SMA + limit) - const ohlcv = await this.getOHLCV(symbol, interval, limit + 60); - - const [sma20, sma50, ema12, ema26, rsi, macd, bollinger, atr] = await Promise.all([ - Promise.resolve(this.calculateSMA(ohlcv, 20)), - Promise.resolve(this.calculateSMA(ohlcv, 50)), - Promise.resolve(this.calculateEMA(ohlcv, 12)), - Promise.resolve(this.calculateEMA(ohlcv, 26)), - Promise.resolve(this.calculateRSI(ohlcv, 14)), - Promise.resolve(this.calculateMACD(ohlcv, 12, 26, 9)), - Promise.resolve(this.calculateBollingerBands(ohlcv, 20, 2)), - Promise.resolve(this.calculateATR(ohlcv, 14)), - ]); - - return { sma20, sma50, ema12, ema26, rsi, macd, bollinger, atr }; - } -} - -export const indicatorsService = new IndicatorsService(); diff --git a/apps/backend/src/modules/trading/services/market.service.ts b/apps/backend/src/modules/trading/services/market.service.ts deleted file mode 100644 index 2d6e3af..0000000 --- a/apps/backend/src/modules/trading/services/market.service.ts +++ /dev/null @@ -1,479 +0,0 @@ -/** - * Market Data Service - * Facade for market data operations combining Binance API and Cache - */ - -import { binanceService, Kline, Ticker24h, OrderBook, Interval, SymbolInfo } from './binance.service'; -import { marketDataCache, MarketDataCache } from './cache.service'; - -// ============================================================================ -// Types -// ============================================================================ - -export interface MarketSymbol { - symbol: string; - baseAsset: string; - quoteAsset: string; - status: string; - pricePrecision: number; - quantityPrecision: number; - minPrice: string; - maxPrice: string; - tickSize: string; - minQty: string; - maxQty: string; - stepSize: string; - minNotional: string; -} - -export interface MarketPrice { - symbol: string; - price: number; - timestamp: number; -} - -export interface MarketTicker { - symbol: string; - lastPrice: number; - priceChange: number; - priceChangePercent: number; - high24h: number; - low24h: number; - volume24h: number; - quoteVolume24h: number; -} - -export interface CandlestickData { - time: number; - open: number; - high: number; - low: number; - close: number; - volume: number; -} - -export interface WatchlistItem { - symbol: string; - price: number; - change24h: number; - changePercent24h: number; - volume24h: number; - high24h: number; - low24h: number; -} - -// ============================================================================ -// Market Service -// ============================================================================ - -class MarketService { - private symbolsInfo: Map = new Map(); - private initialized: boolean = false; - - constructor() { - // Set up WebSocket event handlers - this.setupWebSocketHandlers(); - } - - /** - * Initialize the service by loading exchange info - */ - async initialize(): Promise { - if (this.initialized) return; - - try { - await this.loadExchangeInfo(); - this.initialized = true; - console.log('[MarketService] Initialized successfully'); - } catch (error) { - console.error('[MarketService] Failed to initialize:', error); - throw error; - } - } - - /** - * Load and cache exchange info - */ - async loadExchangeInfo(): Promise { - const cacheKey = MarketDataCache.exchangeInfoKey(); - - const exchangeInfo = await marketDataCache.getOrSet( - cacheKey, - () => binanceService.getExchangeInfo(), - 3600 // Cache for 1 hour - ); - - // Process and store symbol info - for (const symbol of exchangeInfo.symbols) { - if (symbol.status === 'TRADING') { - const marketSymbol = this.parseSymbolInfo(symbol); - this.symbolsInfo.set(symbol.symbol, marketSymbol); - } - } - - console.log(`[MarketService] Loaded ${this.symbolsInfo.size} trading pairs`); - } - - /** - * Get candlestick/kline data - */ - async getKlines( - symbol: string, - interval: Interval, - options: { startTime?: number; endTime?: number; limit?: number } = {} - ): Promise { - const cacheKey = MarketDataCache.klineKey(symbol, interval); - - // For real-time data, use short cache - const klines = await marketDataCache.getOrSet( - cacheKey, - () => binanceService.getKlines(symbol, interval, options), - 5 // 5 seconds cache - ); - - return klines.map(this.transformKline); - } - - /** - * Get current price for a symbol - */ - async getPrice(symbol: string): Promise { - const cacheKey = MarketDataCache.priceKey(symbol); - - const priceData = await marketDataCache.getOrSet( - cacheKey, - async () => { - const result = await binanceService.getPrice(symbol); - return result as { symbol: string; price: string }; - }, - 2 // 2 seconds cache - ); - - return { - symbol: priceData.symbol, - price: parseFloat(priceData.price), - timestamp: Date.now(), - }; - } - - /** - * Get prices for multiple symbols - */ - async getPrices(symbols?: string[]): Promise { - if (symbols && symbols.length === 1) { - return [await this.getPrice(symbols[0])]; - } - - const allPrices = (await binanceService.getPrice()) as { symbol: string; price: string }[]; - - let filtered = allPrices; - if (symbols && symbols.length > 0) { - const symbolSet = new Set(symbols.map((s) => s.toUpperCase())); - filtered = allPrices.filter((p) => symbolSet.has(p.symbol)); - } - - return filtered.map((p) => ({ - symbol: p.symbol, - price: parseFloat(p.price), - timestamp: Date.now(), - })); - } - - /** - * Get 24h ticker for a symbol - */ - async getTicker(symbol: string): Promise { - const cacheKey = MarketDataCache.tickerKey(symbol); - - const ticker = await marketDataCache.getOrSet( - cacheKey, - async () => { - const result = await binanceService.get24hrTicker(symbol); - return result as Ticker24h; - }, - 5 // 5 seconds cache - ); - - return this.transformTicker(ticker); - } - - /** - * Get 24h tickers for multiple symbols - */ - async getTickers(symbols?: string[]): Promise { - if (symbols && symbols.length === 1) { - return [await this.getTicker(symbols[0])]; - } - - const allTickers = (await binanceService.get24hrTicker()) as Ticker24h[]; - - let filtered = allTickers; - if (symbols && symbols.length > 0) { - const symbolSet = new Set(symbols.map((s) => s.toUpperCase())); - filtered = allTickers.filter((t) => symbolSet.has(t.symbol)); - } - - return filtered.map(this.transformTicker); - } - - /** - * Get order book for a symbol - */ - async getOrderBook(symbol: string, limit: number = 20): Promise { - const cacheKey = MarketDataCache.orderBookKey(symbol); - - return marketDataCache.getOrSet(cacheKey, () => binanceService.getOrderBook(symbol, limit), 1); - } - - /** - * Get watchlist data (optimized for multiple symbols) - */ - async getWatchlist(symbols: string[]): Promise { - const tickers = await this.getTickers(symbols); - - return tickers.map((ticker) => ({ - symbol: ticker.symbol, - price: ticker.lastPrice, - change24h: ticker.priceChange, - changePercent24h: ticker.priceChangePercent, - volume24h: ticker.volume24h, - high24h: ticker.high24h, - low24h: ticker.low24h, - })); - } - - /** - * Get symbol info - */ - getSymbolInfo(symbol: string): MarketSymbol | undefined { - return this.symbolsInfo.get(symbol.toUpperCase()); - } - - /** - * Get all available symbols - */ - getAvailableSymbols(): string[] { - return Array.from(this.symbolsInfo.keys()); - } - - /** - * Search symbols by query - */ - searchSymbols(query: string, limit: number = 20): MarketSymbol[] { - const upperQuery = query.toUpperCase(); - const results: MarketSymbol[] = []; - - for (const [symbol, info] of this.symbolsInfo) { - if ( - symbol.includes(upperQuery) || - info.baseAsset.includes(upperQuery) || - info.quoteAsset.includes(upperQuery) - ) { - results.push(info); - if (results.length >= limit) break; - } - } - - return results; - } - - /** - * Get popular symbols (USDT pairs, major cryptos) - */ - getPopularSymbols(): string[] { - const popular = [ - 'BTCUSDT', - 'ETHUSDT', - 'BNBUSDT', - 'XRPUSDT', - 'SOLUSDT', - 'ADAUSDT', - 'DOGEUSDT', - 'DOTUSDT', - 'MATICUSDT', - 'LINKUSDT', - 'AVAXUSDT', - 'ATOMUSDT', - 'LTCUSDT', - 'UNIUSDT', - 'APTUSDT', - ]; - - return popular.filter((s) => this.symbolsInfo.has(s)); - } - - // ========================================================================== - // WebSocket Subscriptions - // ========================================================================== - - /** - * Subscribe to real-time kline updates - */ - subscribeKlines(symbol: string, interval: Interval): void { - binanceService.subscribeKlines(symbol, interval); - } - - /** - * Subscribe to real-time ticker updates - */ - subscribeTicker(symbol: string): void { - binanceService.subscribeTicker(symbol); - } - - /** - * Subscribe to real-time trade updates - */ - subscribeTrades(symbol: string): void { - binanceService.subscribeTrades(symbol); - } - - /** - * Subscribe to order book depth updates - */ - subscribeDepth(symbol: string, levels: 5 | 10 | 20 = 10): void { - binanceService.subscribeDepth(symbol, levels); - } - - /** - * Unsubscribe from a stream - */ - unsubscribe(streamName: string): void { - binanceService.unsubscribe(streamName); - } - - /** - * Unsubscribe from all streams - */ - unsubscribeAll(): void { - binanceService.unsubscribeAll(); - } - - /** - * Get active WebSocket streams - */ - getActiveStreams(): string[] { - return binanceService.getActiveStreams(); - } - - // ========================================================================== - // Event Handlers - // ========================================================================== - - /** - * Register kline event handler - */ - onKline(handler: (data: { symbol: string; interval: string; kline: CandlestickData; isFinal: boolean }) => void): void { - binanceService.on('kline', (data) => { - handler({ - symbol: data.symbol, - interval: data.interval, - kline: this.transformKline(data.kline), - isFinal: data.isFinal, - }); - }); - } - - /** - * Register ticker event handler - */ - onTicker(handler: (data: MarketTicker) => void): void { - binanceService.on('ticker', (data) => { - handler(this.transformTicker(data)); - }); - } - - /** - * Register trade event handler - */ - onTrade( - handler: (data: { - symbol: string; - tradeId: number; - price: number; - quantity: number; - time: number; - isBuyerMaker: boolean; - }) => void - ): void { - binanceService.on('trade', (data) => { - handler({ - symbol: data.symbol, - tradeId: data.tradeId, - price: parseFloat(data.price), - quantity: parseFloat(data.quantity), - time: data.time, - isBuyerMaker: data.isBuyerMaker, - }); - }); - } - - // ========================================================================== - // Private Methods - // ========================================================================== - - private setupWebSocketHandlers(): void { - // Log WebSocket connection events - binanceService.on('wsConnected', (streamName) => { - console.log(`[MarketService] WebSocket connected: ${streamName}`); - }); - - binanceService.on('wsDisconnected', (streamName) => { - console.log(`[MarketService] WebSocket disconnected: ${streamName}`); - }); - - binanceService.on('wsError', ({ streamName, error }) => { - console.error(`[MarketService] WebSocket error on ${streamName}:`, error.message); - }); - } - - private parseSymbolInfo(symbol: SymbolInfo): MarketSymbol { - const filters = symbol.filters; - - const priceFilter = filters.find((f) => f.filterType === 'PRICE_FILTER'); - const lotSizeFilter = filters.find((f) => f.filterType === 'LOT_SIZE'); - const notionalFilter = filters.find((f) => f.filterType === 'NOTIONAL'); - - return { - symbol: symbol.symbol, - baseAsset: symbol.baseAsset, - quoteAsset: symbol.quoteAsset, - status: symbol.status, - pricePrecision: symbol.quotePrecision, - quantityPrecision: symbol.baseAssetPrecision, - minPrice: priceFilter?.minPrice || '0', - maxPrice: priceFilter?.maxPrice || '0', - tickSize: priceFilter?.tickSize || '0', - minQty: lotSizeFilter?.minQty || '0', - maxQty: lotSizeFilter?.maxQty || '0', - stepSize: lotSizeFilter?.stepSize || '0', - minNotional: notionalFilter?.minNotional || '0', - }; - } - - private transformKline(kline: Kline): CandlestickData { - return { - time: kline.openTime, - open: parseFloat(kline.open), - high: parseFloat(kline.high), - low: parseFloat(kline.low), - close: parseFloat(kline.close), - volume: parseFloat(kline.volume), - }; - } - - private transformTicker(ticker: Ticker24h): MarketTicker { - return { - symbol: ticker.symbol, - lastPrice: parseFloat(ticker.lastPrice), - priceChange: parseFloat(ticker.priceChange), - priceChangePercent: parseFloat(ticker.priceChangePercent), - high24h: parseFloat(ticker.highPrice), - low24h: parseFloat(ticker.lowPrice), - volume24h: parseFloat(ticker.volume), - quoteVolume24h: parseFloat(ticker.quoteVolume), - }; - } -} - -// Export singleton instance -export const marketService = new MarketService(); diff --git a/apps/backend/src/modules/trading/services/paper-trading.service.ts b/apps/backend/src/modules/trading/services/paper-trading.service.ts deleted file mode 100644 index 20c4845..0000000 --- a/apps/backend/src/modules/trading/services/paper-trading.service.ts +++ /dev/null @@ -1,775 +0,0 @@ -/** - * Paper Trading Service - * Simulates trading with virtual funds using PostgreSQL - */ - -import { db } from '../../../shared/database'; -import { marketService } from './market.service'; -import { logger } from '../../../shared/utils/logger'; - -// ============================================================================ -// Types (matching trading.paper_trading_accounts and paper_trading_positions) -// ============================================================================ - -export type TradeDirection = 'long' | 'short'; -export type PositionStatus = 'open' | 'closed' | 'pending'; - -export interface PaperAccount { - id: string; - userId: string; - name: string; - initialBalance: number; - currentBalance: number; - currency: string; - totalTrades: number; - winningTrades: number; - totalPnl: number; - maxDrawdown: number; - isActive: boolean; - createdAt: Date; - updatedAt: Date; -} - -export interface PaperPosition { - id: string; - accountId: string; - userId: string; - symbol: string; - direction: TradeDirection; - lotSize: number; - entryPrice: number; - stopLoss?: number; - takeProfit?: number; - exitPrice?: number; - status: PositionStatus; - openedAt: Date; - closedAt?: Date; - closeReason?: string; - realizedPnl?: number; - createdAt: Date; - updatedAt: Date; - // Calculated fields - currentPrice?: number; - unrealizedPnl?: number; - unrealizedPnlPercent?: number; -} - -export interface CreateAccountInput { - name?: string; - initialBalance?: number; - currency?: string; -} - -export interface CreatePositionInput { - symbol: string; - direction: TradeDirection; - lotSize: number; - entryPrice?: number; // If not provided, uses market price - stopLoss?: number; - takeProfit?: number; -} - -export interface ClosePositionInput { - exitPrice?: number; // If not provided, uses market price - closeReason?: string; -} - -export interface AccountSummary { - account: PaperAccount; - openPositions: number; - totalEquity: number; - unrealizedPnl: number; - todayPnl: number; - winRate: number; -} - -// ============================================================================ -// Helper Functions -// ============================================================================ - -function mapAccount(row: Record): PaperAccount { - return { - id: row.id as string, - userId: row.user_id as string, - name: row.name as string, - initialBalance: parseFloat(row.initial_balance as string), - currentBalance: parseFloat(row.current_balance as string), - currency: (row.currency as string).trim(), - totalTrades: row.total_trades as number, - winningTrades: row.winning_trades as number, - totalPnl: parseFloat(row.total_pnl as string), - maxDrawdown: parseFloat(row.max_drawdown as string), - isActive: row.is_active as boolean, - createdAt: new Date(row.created_at as string), - updatedAt: new Date(row.updated_at as string), - }; -} - -function mapPosition(row: Record): PaperPosition { - return { - id: row.id as string, - accountId: row.account_id as string, - userId: row.user_id as string, - symbol: row.symbol as string, - direction: row.direction as TradeDirection, - lotSize: parseFloat(row.lot_size as string), - entryPrice: parseFloat(row.entry_price as string), - stopLoss: row.stop_loss ? parseFloat(row.stop_loss as string) : undefined, - takeProfit: row.take_profit ? parseFloat(row.take_profit as string) : undefined, - exitPrice: row.exit_price ? parseFloat(row.exit_price as string) : undefined, - status: row.status as PositionStatus, - openedAt: new Date(row.opened_at as string), - closedAt: row.closed_at ? new Date(row.closed_at as string) : undefined, - closeReason: row.close_reason as string | undefined, - realizedPnl: row.realized_pnl ? parseFloat(row.realized_pnl as string) : undefined, - createdAt: new Date(row.created_at as string), - updatedAt: new Date(row.updated_at as string), - }; -} - -// ============================================================================ -// Paper Trading Service -// ============================================================================ - -class PaperTradingService { - // ========================================================================== - // Account Management - // ========================================================================== - - /** - * Get or create default paper trading account for user - */ - async getOrCreateAccount(userId: string): Promise { - // Try to find existing active account - const existing = await db.query>( - `SELECT * FROM trading.paper_trading_accounts - WHERE user_id = $1 AND is_active = TRUE - ORDER BY created_at DESC LIMIT 1`, - [userId] - ); - - if (existing.rows.length > 0) { - return mapAccount(existing.rows[0]); - } - - // Create new account with default $100,000 - return this.createAccount(userId, {}); - } - - /** - * Create a new paper trading account - */ - async createAccount(userId: string, input: CreateAccountInput): Promise { - const result = await db.query>( - `INSERT INTO trading.paper_trading_accounts - (user_id, name, initial_balance, current_balance, currency) - VALUES ($1, $2, $3, $3, $4) - RETURNING *`, - [ - userId, - input.name || 'Paper Account', - input.initialBalance || 100000, - input.currency || 'USD', - ] - ); - - logger.info('[PaperTrading] Account created:', { - userId, - accountId: result.rows[0].id, - initialBalance: input.initialBalance || 100000, - }); - - return mapAccount(result.rows[0]); - } - - /** - * Get account by ID - */ - async getAccount(accountId: string, userId: string): Promise { - const result = await db.query>( - `SELECT * FROM trading.paper_trading_accounts WHERE id = $1 AND user_id = $2`, - [accountId, userId] - ); - if (result.rows.length === 0) return null; - return mapAccount(result.rows[0]); - } - - /** - * Get all accounts for user - */ - async getUserAccounts(userId: string): Promise { - const result = await db.query>( - `SELECT * FROM trading.paper_trading_accounts - WHERE user_id = $1 - ORDER BY created_at DESC`, - [userId] - ); - return result.rows.map(mapAccount); - } - - /** - * Reset account to initial state - */ - async resetAccount(accountId: string, userId: string): Promise { - const client = await db.getClient(); - try { - await client.query('BEGIN'); - - // Get account - const accountResult = await client.query>( - `SELECT * FROM trading.paper_trading_accounts WHERE id = $1 AND user_id = $2`, - [accountId, userId] - ); - - if (accountResult.rows.length === 0) { - await client.query('ROLLBACK'); - return null; - } - - // Close all open positions - await client.query( - `UPDATE trading.paper_trading_positions - SET status = 'closed', closed_at = NOW(), close_reason = 'account_reset' - WHERE account_id = $1 AND status = 'open'`, - [accountId] - ); - - // Reset account - const result = await client.query>( - `UPDATE trading.paper_trading_accounts - SET current_balance = initial_balance, - total_trades = 0, - winning_trades = 0, - total_pnl = 0, - max_drawdown = 0, - updated_at = NOW() - WHERE id = $1 - RETURNING *`, - [accountId] - ); - - await client.query('COMMIT'); - - logger.info('[PaperTrading] Account reset:', { accountId, userId }); - return mapAccount(result.rows[0]); - } catch (error) { - await client.query('ROLLBACK'); - throw error; - } finally { - client.release(); - } - } - - // ========================================================================== - // Position Management - // ========================================================================== - - /** - * Open a new position - */ - async openPosition(userId: string, input: CreatePositionInput): Promise { - // Get or create account - const account = await this.getOrCreateAccount(userId); - - // Get market price if not provided - let entryPrice = input.entryPrice; - if (!entryPrice) { - try { - const priceData = await marketService.getPrice(input.symbol); - entryPrice = priceData.price; - } catch { - throw new Error(`Could not get price for ${input.symbol}`); - } - } - - // Calculate required margin (simplified: lot_size * entry_price) - const requiredMargin = input.lotSize * entryPrice; - if (requiredMargin > account.currentBalance) { - throw new Error( - `Insufficient balance. Required: ${requiredMargin.toFixed(2)}, Available: ${account.currentBalance.toFixed(2)}` - ); - } - - const client = await db.getClient(); - try { - await client.query('BEGIN'); - - // Create position - const result = await client.query>( - `INSERT INTO trading.paper_trading_positions - (account_id, user_id, symbol, direction, lot_size, entry_price, stop_loss, take_profit) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - RETURNING *`, - [ - account.id, - userId, - input.symbol.toUpperCase(), - input.direction, - input.lotSize, - entryPrice, - input.stopLoss || null, - input.takeProfit || null, - ] - ); - - // Deduct margin from balance - await client.query( - `UPDATE trading.paper_trading_accounts - SET current_balance = current_balance - $1, - updated_at = NOW() - WHERE id = $2`, - [requiredMargin, account.id] - ); - - await client.query('COMMIT'); - - logger.info('[PaperTrading] Position opened:', { - positionId: result.rows[0].id, - userId, - symbol: input.symbol, - direction: input.direction, - lotSize: input.lotSize, - entryPrice, - }); - - return mapPosition(result.rows[0]); - } catch (error) { - await client.query('ROLLBACK'); - throw error; - } finally { - client.release(); - } - } - - /** - * Close a position - */ - async closePosition( - positionId: string, - userId: string, - input: ClosePositionInput = {} - ): Promise { - const client = await db.getClient(); - try { - await client.query('BEGIN'); - - // Get position - const positionResult = await client.query>( - `SELECT * FROM trading.paper_trading_positions - WHERE id = $1 AND user_id = $2 AND status = 'open'`, - [positionId, userId] - ); - - if (positionResult.rows.length === 0) { - await client.query('ROLLBACK'); - return null; - } - - const position = mapPosition(positionResult.rows[0]); - - // Get exit price - let exitPrice = input.exitPrice; - if (!exitPrice) { - try { - const priceData = await marketService.getPrice(position.symbol); - exitPrice = priceData.price; - } catch { - throw new Error(`Could not get price for ${position.symbol}`); - } - } - - // Calculate P&L - const priceDiff = exitPrice - position.entryPrice; - const realizedPnl = - position.direction === 'long' - ? priceDiff * position.lotSize - : -priceDiff * position.lotSize; - - // Update position - const result = await client.query>( - `UPDATE trading.paper_trading_positions - SET status = 'closed', - exit_price = $1, - closed_at = NOW(), - close_reason = $2, - realized_pnl = $3, - updated_at = NOW() - WHERE id = $4 - RETURNING *`, - [exitPrice, input.closeReason || 'manual', realizedPnl, positionId] - ); - - // Update account balance and stats - const marginReturn = position.lotSize * position.entryPrice; - const isWin = realizedPnl > 0; - - await client.query( - `UPDATE trading.paper_trading_accounts - SET current_balance = current_balance + $1 + $2, - total_trades = total_trades + 1, - winning_trades = winning_trades + $3, - total_pnl = total_pnl + $2, - updated_at = NOW() - WHERE id = $4`, - [marginReturn, realizedPnl, isWin ? 1 : 0, position.accountId] - ); - - // Update max drawdown if needed - await this.updateMaxDrawdown(client, position.accountId); - - await client.query('COMMIT'); - - logger.info('[PaperTrading] Position closed:', { - positionId, - userId, - exitPrice, - realizedPnl, - }); - - return mapPosition(result.rows[0]); - } catch (error) { - await client.query('ROLLBACK'); - throw error; - } finally { - client.release(); - } - } - - /** - * Get position by ID - */ - async getPosition(positionId: string, userId: string): Promise { - const result = await db.query>( - `SELECT * FROM trading.paper_trading_positions WHERE id = $1 AND user_id = $2`, - [positionId, userId] - ); - - if (result.rows.length === 0) return null; - - const position = mapPosition(result.rows[0]); - - // Add current price and unrealized P&L for open positions - if (position.status === 'open') { - await this.enrichPositionWithMarketData(position); - } - - return position; - } - - /** - * Get user positions - */ - async getPositions( - userId: string, - options: { accountId?: string; status?: PositionStatus; symbol?: string; limit?: number } = {} - ): Promise { - const conditions: string[] = ['user_id = $1']; - const params: (string | number)[] = [userId]; - let paramIndex = 2; - - if (options.accountId) { - conditions.push(`account_id = $${paramIndex++}`); - params.push(options.accountId); - } - if (options.status) { - conditions.push(`status = $${paramIndex++}`); - params.push(options.status); - } - if (options.symbol) { - conditions.push(`symbol = $${paramIndex++}`); - params.push(options.symbol.toUpperCase()); - } - - let query = `SELECT * FROM trading.paper_trading_positions - WHERE ${conditions.join(' AND ')} - ORDER BY opened_at DESC`; - - if (options.limit) { - query += ` LIMIT $${paramIndex}`; - params.push(options.limit); - } - - const result = await db.query>(query, params); - const positions = result.rows.map(mapPosition); - - // Enrich open positions with market data - for (const position of positions) { - if (position.status === 'open') { - await this.enrichPositionWithMarketData(position); - } - } - - return positions; - } - - /** - * Update position stop loss / take profit - */ - async updatePosition( - positionId: string, - userId: string, - updates: { stopLoss?: number; takeProfit?: number } - ): Promise { - const fields: string[] = []; - const params: (string | number | null)[] = []; - let paramIndex = 1; - - if (updates.stopLoss !== undefined) { - fields.push(`stop_loss = $${paramIndex++}`); - params.push(updates.stopLoss); - } - if (updates.takeProfit !== undefined) { - fields.push(`take_profit = $${paramIndex++}`); - params.push(updates.takeProfit); - } - - if (fields.length === 0) { - return this.getPosition(positionId, userId); - } - - fields.push(`updated_at = NOW()`); - params.push(positionId, userId); - - const result = await db.query>( - `UPDATE trading.paper_trading_positions - SET ${fields.join(', ')} - WHERE id = $${paramIndex++} AND user_id = $${paramIndex} AND status = 'open' - RETURNING *`, - params - ); - - if (result.rows.length === 0) return null; - return mapPosition(result.rows[0]); - } - - // ========================================================================== - // Account Summary & Analytics - // ========================================================================== - - /** - * Get account summary with live data - */ - async getAccountSummary(userId: string, accountId?: string): Promise { - // Get account - let account: PaperAccount | null; - if (accountId) { - account = await this.getAccount(accountId, userId); - } else { - account = await this.getOrCreateAccount(userId); - } - - if (!account) return null; - - // Get open positions - const openPositions = await this.getPositions(userId, { - accountId: account.id, - status: 'open', - }); - - // Calculate unrealized P&L - let unrealizedPnl = 0; - for (const position of openPositions) { - unrealizedPnl += position.unrealizedPnl || 0; - } - - // Calculate total equity - const totalEquity = account.currentBalance + unrealizedPnl; - - // Calculate today's P&L - const todayStart = new Date(); - todayStart.setHours(0, 0, 0, 0); - - const todayResult = await db.query<{ today_pnl: string }>( - `SELECT COALESCE(SUM(realized_pnl), 0) as today_pnl - FROM trading.paper_trading_positions - WHERE account_id = $1 AND closed_at >= $2`, - [account.id, todayStart.toISOString()] - ); - const todayPnl = parseFloat(todayResult.rows[0].today_pnl) + unrealizedPnl; - - // Calculate win rate - const winRate = account.totalTrades > 0 - ? (account.winningTrades / account.totalTrades) * 100 - : 0; - - return { - account, - openPositions: openPositions.length, - totalEquity, - unrealizedPnl, - todayPnl, - winRate, - }; - } - - /** - * Get trade history - */ - async getTradeHistory( - userId: string, - options: { - accountId?: string; - symbol?: string; - startDate?: Date; - endDate?: Date; - limit?: number; - } = {} - ): Promise { - const conditions: string[] = ['user_id = $1', "status = 'closed'"]; - const params: (string | number)[] = [userId]; - let paramIndex = 2; - - if (options.accountId) { - conditions.push(`account_id = $${paramIndex++}`); - params.push(options.accountId); - } - if (options.symbol) { - conditions.push(`symbol = $${paramIndex++}`); - params.push(options.symbol.toUpperCase()); - } - if (options.startDate) { - conditions.push(`closed_at >= $${paramIndex++}`); - params.push(options.startDate.toISOString()); - } - if (options.endDate) { - conditions.push(`closed_at <= $${paramIndex++}`); - params.push(options.endDate.toISOString()); - } - - let query = `SELECT * FROM trading.paper_trading_positions - WHERE ${conditions.join(' AND ')} - ORDER BY closed_at DESC`; - - if (options.limit) { - query += ` LIMIT $${paramIndex}`; - params.push(options.limit); - } - - const result = await db.query>(query, params); - return result.rows.map(mapPosition); - } - - /** - * Get performance statistics - */ - async getPerformanceStats( - userId: string, - accountId?: string - ): Promise<{ - totalTrades: number; - winningTrades: number; - losingTrades: number; - winRate: number; - totalPnl: number; - averageWin: number; - averageLoss: number; - largestWin: number; - largestLoss: number; - profitFactor: number; - }> { - const account = accountId - ? await this.getAccount(accountId, userId) - : await this.getOrCreateAccount(userId); - - if (!account) { - throw new Error('Account not found'); - } - - const result = await db.query>( - `SELECT - COUNT(*) as total_trades, - COUNT(*) FILTER (WHERE realized_pnl > 0) as winning_trades, - COUNT(*) FILTER (WHERE realized_pnl <= 0) as losing_trades, - COALESCE(SUM(realized_pnl), 0) as total_pnl, - COALESCE(AVG(realized_pnl) FILTER (WHERE realized_pnl > 0), 0) as avg_win, - COALESCE(AVG(realized_pnl) FILTER (WHERE realized_pnl <= 0), 0) as avg_loss, - COALESCE(MAX(realized_pnl), 0) as largest_win, - COALESCE(MIN(realized_pnl), 0) as largest_loss, - COALESCE(SUM(realized_pnl) FILTER (WHERE realized_pnl > 0), 0) as gross_profit, - COALESCE(ABS(SUM(realized_pnl) FILTER (WHERE realized_pnl < 0)), 1) as gross_loss - FROM trading.paper_trading_positions - WHERE account_id = $1 AND status = 'closed'`, - [account.id] - ); - - const stats = result.rows[0]; - const totalTrades = parseInt(stats.total_trades, 10); - const winningTrades = parseInt(stats.winning_trades, 10); - const grossProfit = parseFloat(stats.gross_profit); - const grossLoss = parseFloat(stats.gross_loss); - - return { - totalTrades, - winningTrades, - losingTrades: parseInt(stats.losing_trades, 10), - winRate: totalTrades > 0 ? (winningTrades / totalTrades) * 100 : 0, - totalPnl: parseFloat(stats.total_pnl), - averageWin: parseFloat(stats.avg_win), - averageLoss: parseFloat(stats.avg_loss), - largestWin: parseFloat(stats.largest_win), - largestLoss: parseFloat(stats.largest_loss), - profitFactor: grossLoss > 0 ? grossProfit / grossLoss : grossProfit > 0 ? Infinity : 0, - }; - } - - // ========================================================================== - // Private Helpers - // ========================================================================== - - private async enrichPositionWithMarketData(position: PaperPosition): Promise { - try { - const priceData = await marketService.getPrice(position.symbol); - position.currentPrice = priceData.price; - - const priceDiff = priceData.price - position.entryPrice; - position.unrealizedPnl = - position.direction === 'long' - ? priceDiff * position.lotSize - : -priceDiff * position.lotSize; - - position.unrealizedPnlPercent = - ((position.direction === 'long' ? priceDiff : -priceDiff) / position.entryPrice) * 100; - } catch { - // Keep position without market data if fetch fails - logger.debug('[PaperTrading] Could not get price for position:', { - positionId: position.id, - symbol: position.symbol, - }); - } - } - - private async updateMaxDrawdown(client: { query: typeof db.query }, accountId: string): Promise { - // Calculate max drawdown from equity curve - const result = await client.query>( - `WITH equity_changes AS ( - SELECT - current_balance as initial, - current_balance + COALESCE( - (SELECT SUM(realized_pnl) FROM trading.paper_trading_positions - WHERE account_id = $1 AND status = 'closed'), 0 - ) as current - FROM trading.paper_trading_accounts WHERE id = $1 - ) - SELECT - CASE WHEN initial > 0 - THEN GREATEST(0, (initial - current) / initial * 100) - ELSE 0 - END as drawdown - FROM equity_changes`, - [accountId] - ); - - if (result.rows.length > 0) { - const currentDrawdown = parseFloat(result.rows[0].drawdown); - await client.query( - `UPDATE trading.paper_trading_accounts - SET max_drawdown = GREATEST(max_drawdown, $1) - WHERE id = $2`, - [currentDrawdown, accountId] - ); - } - } -} - -// Export singleton instance -export const paperTradingService = new PaperTradingService(); diff --git a/apps/backend/src/modules/trading/services/watchlist.service.ts b/apps/backend/src/modules/trading/services/watchlist.service.ts deleted file mode 100644 index 32ae699..0000000 --- a/apps/backend/src/modules/trading/services/watchlist.service.ts +++ /dev/null @@ -1,428 +0,0 @@ -/** - * Watchlist Service - * Manages user watchlists and symbols using PostgreSQL trading schema - */ - -import { db } from '../../../shared/database'; - -// ============================================================================ -// Types (matching trading.watchlists and trading.watchlist_items schema) -// ============================================================================ - -export interface Watchlist { - id: string; - userId: string; - name: string; - isDefault: boolean; - createdAt: Date; - updatedAt: Date; - items?: WatchlistItem[]; - itemCount?: number; -} - -export interface WatchlistItem { - id: string; - watchlistId: string; - symbol: string; - sortOrder: number; - notes?: string; - createdAt: Date; -} - -export interface CreateWatchlistInput { - name: string; -} - -export interface UpdateWatchlistInput { - name?: string; - isDefault?: boolean; -} - -export interface AddSymbolInput { - symbol: string; - notes?: string; -} - -export interface UpdateSymbolInput { - notes?: string; - sortOrder?: number; -} - -// ============================================================================ -// Service -// ============================================================================ - -class WatchlistService { - // ========================================================================== - // Watchlist CRUD - // ========================================================================== - - /** - * Get all watchlists for a user - */ - async getUserWatchlists(userId: string): Promise { - const result = await db.query>( - `SELECT - w.*, - COUNT(wi.id)::integer as item_count - FROM trading.watchlists w - LEFT JOIN trading.watchlist_items wi ON wi.watchlist_id = w.id - WHERE w.user_id = $1 - GROUP BY w.id - ORDER BY w.is_default DESC, w.created_at`, - [userId] - ); - - return result.rows.map(this.mapWatchlist); - } - - /** - * Get a single watchlist with items - */ - async getWatchlist(watchlistId: string, userId: string): Promise { - const watchlistResult = await db.query>( - `SELECT * FROM trading.watchlists WHERE id = $1 AND user_id = $2`, - [watchlistId, userId] - ); - - if (watchlistResult.rows.length === 0) { - return null; - } - - const itemsResult = await db.query>( - `SELECT * FROM trading.watchlist_items - WHERE watchlist_id = $1 - ORDER BY sort_order, created_at`, - [watchlistId] - ); - - const watchlist = this.mapWatchlist(watchlistResult.rows[0]); - watchlist.items = itemsResult.rows.map(this.mapWatchlistItem); - watchlist.itemCount = watchlist.items.length; - - return watchlist; - } - - /** - * Get default watchlist for user (creates one if doesn't exist) - */ - async getDefaultWatchlist(userId: string): Promise { - const result = await db.query>( - `SELECT * FROM trading.watchlists WHERE user_id = $1 AND is_default = TRUE`, - [userId] - ); - - if (result.rows.length > 0) { - const watchlist = await this.getWatchlist(result.rows[0].id as string, userId); - return watchlist!; - } - - // Create default watchlist - return this.createWatchlist(userId, { name: 'My Watchlist' }, true); - } - - /** - * Create a new watchlist - */ - async createWatchlist(userId: string, input: CreateWatchlistInput, isDefault: boolean = false): Promise { - // If creating default, unset other defaults - if (isDefault) { - await db.query( - `UPDATE trading.watchlists SET is_default = FALSE WHERE user_id = $1`, - [userId] - ); - } - - const result = await db.query>( - `INSERT INTO trading.watchlists (user_id, name, is_default) - VALUES ($1, $2, $3) - RETURNING *`, - [userId, input.name, isDefault] - ); - - const watchlist = this.mapWatchlist(result.rows[0]); - watchlist.items = []; - watchlist.itemCount = 0; - return watchlist; - } - - /** - * Update a watchlist - */ - async updateWatchlist( - watchlistId: string, - userId: string, - input: UpdateWatchlistInput - ): Promise { - // Verify ownership - const check = await db.query>( - `SELECT * FROM trading.watchlists WHERE id = $1 AND user_id = $2`, - [watchlistId, userId] - ); - - if (check.rows.length === 0) { - return null; - } - - const fields: string[] = []; - const values: (string | boolean)[] = []; - let paramIndex = 1; - - if (input.name !== undefined) { - fields.push(`name = $${paramIndex++}`); - values.push(input.name); - } - - if (input.isDefault === true) { - // Unset other defaults first - await db.query( - `UPDATE trading.watchlists SET is_default = FALSE WHERE user_id = $1`, - [userId] - ); - fields.push(`is_default = TRUE`); - } - - if (fields.length === 0) { - return this.getWatchlist(watchlistId, userId); - } - - fields.push(`updated_at = NOW()`); - values.push(watchlistId, userId); - - const result = await db.query>( - `UPDATE trading.watchlists - SET ${fields.join(', ')} - WHERE id = $${paramIndex++} AND user_id = $${paramIndex} - RETURNING *`, - values - ); - - if (result.rows.length === 0) { - return null; - } - - return this.getWatchlist(watchlistId, userId); - } - - /** - * Delete a watchlist - */ - async deleteWatchlist(watchlistId: string, userId: string): Promise { - // Prevent deleting default watchlist - const watchlist = await db.query>( - `SELECT is_default FROM trading.watchlists WHERE id = $1 AND user_id = $2`, - [watchlistId, userId] - ); - - if (watchlist.rows.length === 0) { - return false; - } - - if (watchlist.rows[0].is_default) { - throw new Error('Cannot delete default watchlist'); - } - - const result = await db.query( - `DELETE FROM trading.watchlists WHERE id = $1 AND user_id = $2`, - [watchlistId, userId] - ); - - return (result.rowCount ?? 0) > 0; - } - - // ========================================================================== - // Watchlist Items CRUD - // ========================================================================== - - /** - * Add a symbol to a watchlist - */ - async addSymbol( - watchlistId: string, - userId: string, - input: AddSymbolInput - ): Promise { - // Verify ownership - const ownerCheck = await db.query>( - `SELECT id FROM trading.watchlists WHERE id = $1 AND user_id = $2`, - [watchlistId, userId] - ); - - if (ownerCheck.rows.length === 0) { - return null; - } - - // Get next sort order - const sortResult = await db.query<{ next_order: number }>( - `SELECT COALESCE(MAX(sort_order), -1) + 1 as next_order - FROM trading.watchlist_items WHERE watchlist_id = $1`, - [watchlistId] - ); - const sortOrder = sortResult.rows[0].next_order; - - try { - const result = await db.query>( - `INSERT INTO trading.watchlist_items (watchlist_id, symbol, notes, sort_order) - VALUES ($1, $2, $3, $4) - RETURNING *`, - [watchlistId, input.symbol.toUpperCase(), input.notes || null, sortOrder] - ); - - return this.mapWatchlistItem(result.rows[0]); - } catch (error: unknown) { - // Duplicate symbol (unique constraint) - if ((error as { code?: string }).code === '23505') { - throw new Error('Symbol already in watchlist'); - } - throw error; - } - } - - /** - * Update a symbol in a watchlist - */ - async updateSymbol( - watchlistId: string, - symbol: string, - userId: string, - input: UpdateSymbolInput - ): Promise { - // Verify ownership - const ownerCheck = await db.query>( - `SELECT wi.id FROM trading.watchlist_items wi - JOIN trading.watchlists w ON w.id = wi.watchlist_id - WHERE wi.watchlist_id = $1 AND wi.symbol = $2 AND w.user_id = $3`, - [watchlistId, symbol.toUpperCase(), userId] - ); - - if (ownerCheck.rows.length === 0) { - return null; - } - - const fields: string[] = []; - const values: (string | number | null)[] = []; - let paramIndex = 1; - - if (input.notes !== undefined) { - fields.push(`notes = $${paramIndex++}`); - values.push(input.notes ?? null); - } - if (input.sortOrder !== undefined) { - fields.push(`sort_order = $${paramIndex++}`); - values.push(input.sortOrder); - } - - if (fields.length === 0) { - const current = await db.query>( - `SELECT * FROM trading.watchlist_items - WHERE watchlist_id = $1 AND symbol = $2`, - [watchlistId, symbol.toUpperCase()] - ); - return current.rows[0] ? this.mapWatchlistItem(current.rows[0]) : null; - } - - values.push(watchlistId, symbol.toUpperCase()); - - const result = await db.query>( - `UPDATE trading.watchlist_items - SET ${fields.join(', ')} - WHERE watchlist_id = $${paramIndex++} AND symbol = $${paramIndex} - RETURNING *`, - values - ); - - if (result.rows.length === 0) { - return null; - } - - return this.mapWatchlistItem(result.rows[0]); - } - - /** - * Remove a symbol from a watchlist - */ - async removeSymbol(watchlistId: string, symbol: string, userId: string): Promise { - const result = await db.query( - `DELETE FROM trading.watchlist_items wi - USING trading.watchlists w - WHERE wi.watchlist_id = w.id - AND wi.watchlist_id = $1 - AND wi.symbol = $2 - AND w.user_id = $3`, - [watchlistId, symbol.toUpperCase(), userId] - ); - - return (result.rowCount ?? 0) > 0; - } - - /** - * Reorder symbols in a watchlist - */ - async reorderSymbols( - watchlistId: string, - userId: string, - symbolOrder: string[] - ): Promise { - // Verify ownership - const ownerCheck = await db.query>( - `SELECT id FROM trading.watchlists WHERE id = $1 AND user_id = $2`, - [watchlistId, userId] - ); - - if (ownerCheck.rows.length === 0) { - return false; - } - - // Update sort orders - const client = await db.getClient(); - try { - await client.query('BEGIN'); - - for (let i = 0; i < symbolOrder.length; i++) { - await client.query( - `UPDATE trading.watchlist_items - SET sort_order = $1 - WHERE watchlist_id = $2 AND symbol = $3`, - [i, watchlistId, symbolOrder[i].toUpperCase()] - ); - } - - await client.query('COMMIT'); - return true; - } catch (error) { - await client.query('ROLLBACK'); - throw error; - } finally { - client.release(); - } - } - - // ========================================================================== - // Helpers - // ========================================================================== - - private mapWatchlist(row: Record): Watchlist { - return { - id: row.id as string, - userId: row.user_id as string, - name: row.name as string, - isDefault: row.is_default as boolean, - createdAt: new Date(row.created_at as string), - updatedAt: new Date(row.updated_at as string), - itemCount: row.item_count as number | undefined, - }; - } - - private mapWatchlistItem(row: Record): WatchlistItem { - return { - id: row.id as string, - watchlistId: row.watchlist_id as string, - symbol: row.symbol as string, - sortOrder: row.sort_order as number, - notes: row.notes as string | undefined, - createdAt: new Date(row.created_at as string), - }; - } -} - -export const watchlistService = new WatchlistService(); diff --git a/apps/backend/src/modules/trading/trading.routes.ts b/apps/backend/src/modules/trading/trading.routes.ts deleted file mode 100644 index 9dc4e78..0000000 --- a/apps/backend/src/modules/trading/trading.routes.ts +++ /dev/null @@ -1,365 +0,0 @@ -/** - * Trading Routes - * Market data, paper trading, and watchlist endpoints - */ - -import { Router, RequestHandler } from 'express'; -import * as tradingController from './controllers/trading.controller'; -import * as watchlistController from './controllers/watchlist.controller'; -import * as indicatorsController from './controllers/indicators.controller'; -import * as alertsController from './controllers/alerts.controller'; -import { requireAuth } from '../../core/guards/auth.guard'; - -const router = Router(); - -// Type cast helper for authenticated routes -// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -const authHandler = (fn: Function): RequestHandler => fn as RequestHandler; - -// ============================================================================ -// Market Data Routes (Public) -// ============================================================================ - -/** - * GET /api/v1/trading/market/klines/:symbol - * Get candlestick/kline data for a symbol - * Query params: interval, startTime, endTime, limit - */ -router.get('/market/klines/:symbol', tradingController.getKlines); - -/** - * GET /api/v1/trading/market/price/:symbol - * Get current price for a symbol - */ -router.get('/market/price/:symbol', tradingController.getPrice); - -/** - * GET /api/v1/trading/market/prices - * Get prices for multiple symbols - * Query params: symbols (comma-separated) - */ -router.get('/market/prices', tradingController.getPrices); - -/** - * GET /api/v1/trading/market/ticker/:symbol - * Get 24h ticker for a symbol - */ -router.get('/market/ticker/:symbol', tradingController.getTicker); - -/** - * GET /api/v1/trading/market/tickers - * Get tickers for multiple symbols - * Query params: symbols (comma-separated) - */ -router.get('/market/tickers', tradingController.getTickers); - -/** - * GET /api/v1/trading/market/orderbook/:symbol - * Get order book for a symbol - * Query params: limit - */ -router.get('/market/orderbook/:symbol', tradingController.getOrderBook); - -/** - * GET /api/v1/trading/market/search - * Search for symbols - * Query params: query, limit - */ -router.get('/market/search', tradingController.searchSymbols); - -/** - * GET /api/v1/trading/market/popular - * Get popular trading pairs with tickers - */ -router.get('/market/popular', tradingController.getPopularSymbols); - -/** - * GET /api/v1/trading/market/watchlist - * Get watchlist data for symbols - * Query params: symbols (comma-separated, required) - */ -router.get('/market/watchlist', tradingController.getWatchlist); - -// ============================================================================ -// Technical Indicators Routes (Public) -// ============================================================================ - -/** - * GET /api/v1/trading/indicators/:symbol/sma - * Get Simple Moving Average - * Query params: interval, period (default 20), limit - */ -router.get('/indicators/:symbol/sma', indicatorsController.getSMA); - -/** - * GET /api/v1/trading/indicators/:symbol/ema - * Get Exponential Moving Average - * Query params: interval, period (default 20), limit - */ -router.get('/indicators/:symbol/ema', indicatorsController.getEMA); - -/** - * GET /api/v1/trading/indicators/:symbol/rsi - * Get Relative Strength Index - * Query params: interval, period (default 14), limit - */ -router.get('/indicators/:symbol/rsi', indicatorsController.getRSI); - -/** - * GET /api/v1/trading/indicators/:symbol/macd - * Get MACD - * Query params: interval, fastPeriod (12), slowPeriod (26), signalPeriod (9), limit - */ -router.get('/indicators/:symbol/macd', indicatorsController.getMACD); - -/** - * GET /api/v1/trading/indicators/:symbol/stochastic - * Get Stochastic Oscillator - * Query params: interval, kPeriod (14), dPeriod (3), smoothK (3), limit - */ -router.get('/indicators/:symbol/stochastic', indicatorsController.getStochastic); - -/** - * GET /api/v1/trading/indicators/:symbol/bollinger - * Get Bollinger Bands - * Query params: interval, period (20), stdDev (2), limit - */ -router.get('/indicators/:symbol/bollinger', indicatorsController.getBollingerBands); - -/** - * GET /api/v1/trading/indicators/:symbol/atr - * Get Average True Range - * Query params: interval, period (14), limit - */ -router.get('/indicators/:symbol/atr', indicatorsController.getATR); - -/** - * GET /api/v1/trading/indicators/:symbol/vwap - * Get Volume Weighted Average Price - * Query params: interval, limit - */ -router.get('/indicators/:symbol/vwap', indicatorsController.getVWAP); - -/** - * GET /api/v1/trading/indicators/:symbol/all - * Get all common indicators at once - * Query params: interval, limit - */ -router.get('/indicators/:symbol/all', indicatorsController.getAllIndicators); - -// ============================================================================ -// Paper Trading Routes (Authenticated) -// TODO: Add authentication middleware -// ============================================================================ - -/** - * POST /api/v1/trading/paper/initialize - * Initialize paper trading account - * Body: { initialCapital?: number } - */ -router.post('/paper/initialize', authHandler(tradingController.initializePaperAccount)); - -/** - * GET /api/v1/trading/paper/balances - * Get paper trading balances - */ -router.get('/paper/balances', authHandler(tradingController.getPaperBalances)); - -/** - * POST /api/v1/trading/paper/orders - * Create a paper trading order - * Body: CreateOrderInput - */ -router.post('/paper/orders', authHandler(tradingController.createPaperOrder)); - -/** - * DELETE /api/v1/trading/paper/orders/:orderId - * Cancel a paper trading order - */ -router.delete('/paper/orders/:orderId', authHandler(tradingController.cancelPaperOrder)); - -/** - * GET /api/v1/trading/paper/orders - * Get paper trading orders - * Query params: status, symbol, limit - */ -router.get('/paper/orders', authHandler(tradingController.getPaperOrders)); - -/** - * GET /api/v1/trading/paper/positions - * Get paper trading positions - * Query params: status, symbol - */ -router.get('/paper/positions', authHandler(tradingController.getPaperPositions)); - -/** - * POST /api/v1/trading/paper/positions/:positionId/close - * Close a paper trading position - * Body: { quantity?: number } - */ -router.post('/paper/positions/:positionId/close', authHandler(tradingController.closePaperPosition)); - -/** - * GET /api/v1/trading/paper/trades - * Get paper trading trades history - * Query params: symbol, limit, startTime, endTime - */ -router.get('/paper/trades', authHandler(tradingController.getPaperTrades)); - -/** - * GET /api/v1/trading/paper/portfolio - * Get paper trading portfolio summary - */ -router.get('/paper/portfolio', authHandler(tradingController.getPaperPortfolio)); - -/** - * POST /api/v1/trading/paper/reset - * Reset paper trading account - */ -router.post('/paper/reset', authHandler(tradingController.resetPaperAccount)); - -/** - * GET /api/v1/trading/paper/settings - * Get paper trading settings - */ -router.get('/paper/settings', authHandler(tradingController.getPaperSettings)); - -/** - * PATCH /api/v1/trading/paper/settings - * Update paper trading settings - * Body: Partial - */ -router.patch('/paper/settings', authHandler(tradingController.updatePaperSettings)); - -/** - * GET /api/v1/trading/paper/stats - * Get paper trading performance statistics - * Query params: accountId - */ -router.get('/paper/stats', authHandler(tradingController.getPaperStats)); - -// ============================================================================ -// Watchlist Routes (Authenticated) -// ============================================================================ - -/** - * GET /api/v1/trading/watchlists - * Get all user's watchlists - */ -router.get('/watchlists', authHandler(watchlistController.getUserWatchlists)); - -/** - * GET /api/v1/trading/watchlists/default - * Get user's default watchlist - */ -router.get('/watchlists/default', authHandler(watchlistController.getDefaultWatchlist)); - -/** - * GET /api/v1/trading/watchlists/:watchlistId - * Get a single watchlist with items - */ -router.get('/watchlists/:watchlistId', authHandler(watchlistController.getWatchlist)); - -/** - * POST /api/v1/trading/watchlists - * Create a new watchlist - * Body: CreateWatchlistInput - */ -router.post('/watchlists', authHandler(watchlistController.createWatchlist)); - -/** - * PATCH /api/v1/trading/watchlists/:watchlistId - * Update a watchlist - * Body: UpdateWatchlistInput - */ -router.patch('/watchlists/:watchlistId', authHandler(watchlistController.updateWatchlist)); - -/** - * DELETE /api/v1/trading/watchlists/:watchlistId - * Delete a watchlist - */ -router.delete('/watchlists/:watchlistId', authHandler(watchlistController.deleteWatchlist)); - -/** - * POST /api/v1/trading/watchlists/:watchlistId/symbols - * Add a symbol to a watchlist - * Body: AddSymbolInput - */ -router.post('/watchlists/:watchlistId/symbols', authHandler(watchlistController.addSymbol)); - -/** - * PATCH /api/v1/trading/watchlists/:watchlistId/symbols/:symbol - * Update a symbol in a watchlist - * Body: UpdateSymbolInput - */ -router.patch('/watchlists/:watchlistId/symbols/:symbol', authHandler(watchlistController.updateSymbol)); - -/** - * DELETE /api/v1/trading/watchlists/:watchlistId/symbols/:symbol - * Remove a symbol from a watchlist - */ -router.delete('/watchlists/:watchlistId/symbols/:symbol', authHandler(watchlistController.removeSymbol)); - -/** - * POST /api/v1/trading/watchlists/:watchlistId/reorder - * Reorder symbols in a watchlist - * Body: { symbolOrder: string[] } - */ -router.post('/watchlists/:watchlistId/reorder', authHandler(watchlistController.reorderSymbols)); - -// ============================================================================ -// Price Alerts Routes (Authenticated) -// ============================================================================ - -/** - * GET /api/v1/trading/alerts - * Get user's price alerts - * Query: status, symbol, alertType - */ -router.get('/alerts', authHandler(requireAuth), authHandler(alertsController.getAlerts)); - -/** - * GET /api/v1/trading/alerts/stats - * Get alert statistics - */ -router.get('/alerts/stats', authHandler(requireAuth), authHandler(alertsController.getAlertStats)); - -/** - * GET /api/v1/trading/alerts/:alertId - * Get a specific alert - */ -router.get('/alerts/:alertId', authHandler(requireAuth), authHandler(alertsController.getAlertById)); - -/** - * POST /api/v1/trading/alerts - * Create a new price alert - * Body: { symbol, alertType, targetPrice, percentThreshold?, notificationChannels?, message?, expiresAt? } - */ -router.post('/alerts', authHandler(requireAuth), authHandler(alertsController.createAlert)); - -/** - * PATCH /api/v1/trading/alerts/:alertId - * Update an alert - * Body: { targetPrice?, percentThreshold?, notificationChannels?, message?, expiresAt?, status? } - */ -router.patch('/alerts/:alertId', authHandler(requireAuth), authHandler(alertsController.updateAlert)); - -/** - * DELETE /api/v1/trading/alerts/:alertId - * Delete an alert - */ -router.delete('/alerts/:alertId', authHandler(requireAuth), authHandler(alertsController.deleteAlert)); - -/** - * POST /api/v1/trading/alerts/:alertId/enable - * Enable an alert - */ -router.post('/alerts/:alertId/enable', authHandler(requireAuth), authHandler(alertsController.enableAlert)); - -/** - * POST /api/v1/trading/alerts/:alertId/disable - * Disable an alert - */ -router.post('/alerts/:alertId/disable', authHandler(requireAuth), authHandler(alertsController.disableAlert)); - -export { router as tradingRouter }; diff --git a/apps/backend/src/modules/trading/types/market.types.ts b/apps/backend/src/modules/trading/types/market.types.ts deleted file mode 100644 index b45043d..0000000 --- a/apps/backend/src/modules/trading/types/market.types.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Market Data Types - * ================= - * Type definitions for market data from Binance API - */ - -export interface Kline { - openTime: number; - open: string; - high: string; - low: string; - close: string; - volume: string; - closeTime: number; - quoteVolume: string; - trades: number; - takerBuyBaseVolume: string; - takerBuyQuoteVolume: string; -} - -export interface Ticker24hr { - symbol: string; - priceChange: string; - priceChangePercent: string; - weightedAvgPrice: string; - lastPrice: string; - lastQty: string; - bidPrice: string; - bidQty: string; - askPrice: string; - askQty: string; - openPrice: string; - highPrice: string; - lowPrice: string; - volume: string; - quoteVolume: string; - openTime: number; - closeTime: number; - firstId: number; - lastId: number; - count: number; -} - -export interface OrderBook { - lastUpdateId: number; - bids: [string, string][]; // [price, quantity] - asks: [string, string][]; -} - -export interface SymbolInfo { - symbol: string; - status: string; - baseAsset: string; - baseAssetPrecision: number; - quoteAsset: string; - quotePrecision: number; - quoteAssetPrecision: number; - orderTypes: string[]; - icebergAllowed: boolean; - ocoAllowed: boolean; - isSpotTradingAllowed: boolean; - isMarginTradingAllowed: boolean; - filters: Record[]; - permissions: string[]; -} - -export interface ExchangeInfo { - timezone: string; - serverTime: number; - rateLimits: RateLimit[]; - exchangeFilters: Record[]; - symbols: SymbolInfo[]; -} - -export interface RateLimit { - rateLimitType: string; - interval: string; - intervalNum: number; - limit: number; -} - -export interface PriceResponse { - symbol: string; - price: string; -} - -export type KlineInterval = - | '1s' | '1m' | '3m' | '5m' | '15m' | '30m' - | '1h' | '2h' | '4h' | '6h' | '8h' | '12h' - | '1d' | '3d' | '1w' | '1M'; - -export interface GetKlinesParams { - symbol: string; - interval: KlineInterval; - startTime?: number; - endTime?: number; - limit?: number; // Max 1000 -} - -export interface CachedData { - data: T; - cached: boolean; - cachedAt?: number; -} diff --git a/apps/backend/src/modules/users/users.routes.ts b/apps/backend/src/modules/users/users.routes.ts deleted file mode 100644 index 127af92..0000000 --- a/apps/backend/src/modules/users/users.routes.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Users Routes - */ - -import { Router, Request, Response } from 'express'; - -const router = Router(); - -/** - * GET /api/v1/users - * List users (admin only) - */ -router.get('/', async (req: Request, res: Response) => { - res.status(501).json({ - success: false, - error: { message: 'Not implemented yet', code: 'NOT_IMPLEMENTED' }, - }); -}); - -/** - * GET /api/v1/users/me - * Get current user profile - */ -router.get('/me', async (req: Request, res: Response) => { - res.status(501).json({ - success: false, - error: { message: 'Not implemented yet', code: 'NOT_IMPLEMENTED' }, - }); -}); - -/** - * GET /api/v1/users/:id - * Get user by ID - */ -router.get('/:id', async (req: Request, res: Response) => { - res.status(501).json({ - success: false, - error: { message: 'Not implemented yet', code: 'NOT_IMPLEMENTED' }, - }); -}); - -/** - * PATCH /api/v1/users/me - * Update current user profile - */ -router.patch('/me', async (req: Request, res: Response) => { - res.status(501).json({ - success: false, - error: { message: 'Not implemented yet', code: 'NOT_IMPLEMENTED' }, - }); -}); - -/** - * DELETE /api/v1/users/:id - * Delete user (admin only) - */ -router.delete('/:id', async (req: Request, res: Response) => { - res.status(501).json({ - success: false, - error: { message: 'Not implemented yet', code: 'NOT_IMPLEMENTED' }, - }); -}); - -export { router as usersRouter }; diff --git a/apps/backend/src/shared/clients/index.ts b/apps/backend/src/shared/clients/index.ts deleted file mode 100644 index 0030b19..0000000 --- a/apps/backend/src/shared/clients/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * External Service Clients - * - * Clients for communicating with Python microservices: - * - Trading Agents (port 8004) - * - ML Engine (port 8001) - * - LLM Agent (port 8003) - */ - -export * from './trading-agents.client'; -export * from './ml-engine.client'; -export * from './llm-agent.client'; diff --git a/apps/backend/src/shared/clients/llm-agent.client.ts b/apps/backend/src/shared/clients/llm-agent.client.ts deleted file mode 100644 index a38b700..0000000 --- a/apps/backend/src/shared/clients/llm-agent.client.ts +++ /dev/null @@ -1,414 +0,0 @@ -/** - * LLM Agent Client - * Client for communicating with the LLM Agent Python service (Trading Copilot) - * - * @see apps/llm-agent/IMPLEMENTATION_SUMMARY.md - */ - -import axios, { AxiosInstance, AxiosError } from 'axios'; -import { logger } from '../utils/logger'; - -// ============================================================================ -// Types -// ============================================================================ - -export type UserPlan = 'free' | 'pro' | 'premium'; -export type UserLevel = 'beginner' | 'intermediate' | 'advanced'; - -export interface ChatMessage { - role: 'user' | 'assistant' | 'system'; - content: string; - timestamp?: string; - tool_calls?: ToolCall[]; -} - -export interface ToolCall { - id: string; - name: string; - arguments: Record; - result?: unknown; -} - -export interface ChatRequest { - user_id: string; - conversation_id?: string; - message: string; - user_plan?: UserPlan; - user_level?: UserLevel; - stream?: boolean; - context?: { - current_symbol?: string; - current_timeframe?: string; - portfolio_value?: number; - }; -} - -export interface ChatResponse { - conversation_id: string; - message: ChatMessage; - tools_used: string[]; - tokens_used: number; - model: string; - processing_time_ms: number; -} - -export interface AnalyzeRequest { - user_id: string; - symbol: string; - timeframe?: string; - user_plan?: UserPlan; - include_ml_signal?: boolean; - include_amd?: boolean; -} - -export interface AnalyzeResponse { - symbol: string; - analysis: string; - technical_summary: { - trend: 'bullish' | 'bearish' | 'neutral'; - strength: number; - key_levels: { - support: number[]; - resistance: number[]; - }; - indicators: Record; - }; - ml_signal?: { - direction: string; - confidence: number; - entry: number; - stop_loss: number; - take_profit: number; - }; - amd_phase?: { - phase: string; - confidence: number; - recommendations: string[]; - }; - risk_assessment: string; - disclaimer: string; -} - -export interface StrategyRequest { - user_id: string; - symbol: string; - risk_tolerance: 'conservative' | 'moderate' | 'aggressive'; - capital?: number; - timeframe?: string; - user_plan?: UserPlan; -} - -export interface StrategyResponse { - symbol: string; - strategy_name: string; - description: string; - entry_conditions: string[]; - exit_conditions: string[]; - position_sizing: { - max_position_percent: number; - recommended_lot_size: number; - stop_loss_distance: number; - }; - risk_reward_ratio: number; - expected_win_rate: number; - backtested_results?: { - period: string; - total_return: number; - max_drawdown: number; - sharpe_ratio: number; - }; - warnings: string[]; - disclaimer: string; -} - -export interface ExplainRequest { - concept: string; - level?: UserLevel; - with_examples?: boolean; -} - -export interface ExplainResponse { - concept: string; - explanation: string; - examples?: string[]; - related_concepts: string[]; - resources?: { title: string; url: string }[]; -} - -export interface ToolInfo { - name: string; - description: string; - required_plan: UserPlan; - parameters: Record; -} - -export interface ConversationHistory { - conversation_id: string; - user_id: string; - messages: ChatMessage[]; - created_at: string; - last_message_at: string; - total_messages: number; -} - -export interface LLMAgentHealthResponse { - status: 'healthy' | 'unhealthy'; - version: string; - llm_provider: string; - llm_model: string; - tools_available: number; - ollama_status: 'connected' | 'disconnected'; -} - -// ============================================================================ -// Client Implementation -// ============================================================================ - -class LLMAgentClient { - private client: AxiosInstance; - private baseUrl: string; - - constructor() { - this.baseUrl = process.env.LLM_AGENT_URL || 'http://localhost:8003'; - - this.client = axios.create({ - baseURL: this.baseUrl, - timeout: 120000, // LLM responses can take time - headers: { - 'Content-Type': 'application/json', - }, - }); - - // Request interceptor - this.client.interceptors.request.use( - (config) => { - logger.debug('[LLMAgentClient] Request:', { - method: config.method?.toUpperCase(), - url: config.url, - }); - return config; - }, - (error) => { - logger.error('[LLMAgentClient] Request error:', error.message); - return Promise.reject(error); - } - ); - - // Response interceptor - this.client.interceptors.response.use( - (response) => response, - (error: AxiosError) => { - const status = error.response?.status; - const message = (error.response?.data as { detail?: string })?.detail || error.message; - - logger.error('[LLMAgentClient] Response error:', { - status, - message, - url: error.config?.url, - }); - - throw new Error(`LLM Agent API error: ${message}`); - } - ); - } - - // ========================================================================== - // Health & Status - // ========================================================================== - - /** - * Check if LLM Agent service is healthy - */ - async healthCheck(): Promise { - const response = await this.client.get('/api/v1/health'); - return response.data; - } - - /** - * Check if service is available - */ - async isAvailable(): Promise { - try { - await this.healthCheck(); - return true; - } catch { - return false; - } - } - - /** - * Get available tools for a user plan - */ - async getTools(userPlan: UserPlan = 'free'): Promise { - const response = await this.client.get<{ tools: ToolInfo[] }>('/api/v1/tools', { - params: { user_plan: userPlan }, - }); - return response.data.tools; - } - - /** - * Get available models - */ - async getModels(): Promise<{ models: string[]; default: string }> { - const response = await this.client.get<{ models: string[]; default: string }>( - '/api/v1/models' - ); - return response.data; - } - - // ========================================================================== - // Chat & Conversation - // ========================================================================== - - /** - * Send a chat message and get response - */ - async chat(request: ChatRequest): Promise { - const response = await this.client.post('/api/v1/chat', { - ...request, - stream: false, // Non-streaming for this method - }); - - logger.debug('[LLMAgentClient] Chat response:', { - conversationId: response.data.conversation_id, - toolsUsed: response.data.tools_used, - tokens: response.data.tokens_used, - }); - - return response.data; - } - - /** - * Get streaming chat response URL - * Returns the URL to connect via SSE for streaming responses - */ - getStreamingChatUrl(): string { - return `${this.baseUrl}/api/v1/chat/stream`; - } - - /** - * Get conversation history - */ - async getConversation( - userId: string, - conversationId: string - ): Promise { - try { - const response = await this.client.get( - `/api/v1/conversations/${userId}/${conversationId}` - ); - return response.data; - } catch (error) { - if ((error as AxiosError).response?.status === 404) { - return null; - } - throw error; - } - } - - /** - * Get all conversations for a user - */ - async getUserConversations( - userId: string, - options?: { limit?: number; offset?: number } - ): Promise { - const response = await this.client.get<{ conversations: ConversationHistory[] }>( - `/api/v1/conversations/${userId}`, - { params: options } - ); - return response.data.conversations; - } - - /** - * Delete a conversation - */ - async deleteConversation(userId: string, conversationId: string): Promise { - await this.client.delete(`/api/v1/context/${userId}/${conversationId}`); - - logger.info('[LLMAgentClient] Conversation deleted:', { - userId, - conversationId, - }); - } - - // ========================================================================== - // Analysis & Strategy - // ========================================================================== - - /** - * Get comprehensive analysis for a symbol - */ - async analyze(request: AnalyzeRequest): Promise { - const response = await this.client.post('/api/v1/analyze', request); - - logger.debug('[LLMAgentClient] Analysis completed:', { - symbol: request.symbol, - trend: response.data.technical_summary.trend, - }); - - return response.data; - } - - /** - * Generate trading strategy for a symbol - */ - async generateStrategy(request: StrategyRequest): Promise { - const response = await this.client.post('/api/v1/strategy', request); - - logger.debug('[LLMAgentClient] Strategy generated:', { - symbol: request.symbol, - strategy: response.data.strategy_name, - }); - - return response.data; - } - - // ========================================================================== - // Education - // ========================================================================== - - /** - * Explain a trading concept - */ - async explainConcept(request: ExplainRequest): Promise { - const response = await this.client.post('/api/v1/explain', request); - return response.data; - } - - /** - * Get available concepts that can be explained - */ - async getAvailableConcepts(): Promise { - const response = await this.client.get<{ concepts: string[] }>('/api/v1/concepts'); - return response.data.concepts; - } - - // ========================================================================== - // Utility Methods - // ========================================================================== - - /** - * Format a chat request for streaming via SSE - */ - formatStreamingRequest(request: ChatRequest): string { - return JSON.stringify({ - ...request, - stream: true, - }); - } - - /** - * Parse SSE event data - */ - parseSSEEvent(data: string): { type: string; content: string } | null { - try { - return JSON.parse(data); - } catch { - return null; - } - } -} - -// Export singleton instance -export const llmAgentClient = new LLMAgentClient(); diff --git a/apps/backend/src/shared/clients/ml-engine.client.ts b/apps/backend/src/shared/clients/ml-engine.client.ts deleted file mode 100644 index ee0b388..0000000 --- a/apps/backend/src/shared/clients/ml-engine.client.ts +++ /dev/null @@ -1,397 +0,0 @@ -/** - * ML Engine Client - * Client for communicating with the ML Engine Python service - * - * @see apps/ml-engine/MIGRATION_REPORT.md - */ - -import axios, { AxiosInstance, AxiosError } from 'axios'; -import { logger } from '../utils/logger'; - -// ============================================================================ -// Types -// ============================================================================ - -export type Timeframe = '1m' | '5m' | '15m' | '30m' | '1h' | '4h' | '1d' | '1w'; -export type SignalDirection = 'long' | 'short' | 'neutral'; -export type AMDPhase = 'accumulation' | 'manipulation' | 'distribution' | 'unknown'; -export type VolatilityRegime = 'low' | 'medium' | 'high' | 'extreme'; - -export interface MLSignal { - symbol: string; - timeframe: Timeframe; - direction: SignalDirection; - confidence: number; - entry_price: number; - stop_loss: number; - take_profit: number; - risk_reward_ratio: number; - predicted_delta_high: number; - predicted_delta_low: number; - prob_tp_first: number; - amd_phase: AMDPhase; - volatility_regime: VolatilityRegime; - timestamp: string; - metadata?: Record; -} - -export interface SignalRequest { - symbol: string; - timeframe?: Timeframe; - include_features?: boolean; -} - -export interface BatchSignalRequest { - symbols: string[]; - timeframe?: Timeframe; -} - -export interface AMDAnalysis { - symbol: string; - timeframe: Timeframe; - current_phase: AMDPhase; - phase_confidence: number; - phase_duration_bars: number; - expected_next_phase: AMDPhase; - transition_probability: number; - key_levels: { - accumulation_low: number; - manipulation_high: number; - distribution_target: number; - }; - recommendations: string[]; -} - -export interface RangePrediction { - symbol: string; - timeframe: Timeframe; - current_price: number; - predicted_high: number; - predicted_low: number; - delta_high: number; - delta_low: number; - confidence_high: number; - confidence_low: number; - prediction_horizon: string; -} - -export interface BacktestRequest { - symbol: string; - timeframe: Timeframe; - start_date: string; - end_date: string; - strategy?: string; - initial_capital?: number; - risk_per_trade?: number; -} - -export interface BacktestResult { - symbol: string; - timeframe: Timeframe; - period: { start: string; end: string }; - metrics: { - total_trades: number; - winning_trades: number; - losing_trades: number; - win_rate: number; - total_return: number; - total_return_percent: number; - max_drawdown: number; - sharpe_ratio: number; - sortino_ratio: number; - profit_factor: number; - avg_trade_duration: string; - }; - trades: Array<{ - entry_date: string; - exit_date: string; - direction: SignalDirection; - entry_price: number; - exit_price: number; - pnl: number; - pnl_percent: number; - }>; - equity_curve: Array<{ date: string; equity: number }>; -} - -export interface ModelInfo { - name: string; - version: string; - trained_at: string; - symbols: string[]; - timeframes: Timeframe[]; - metrics: { - accuracy: number; - precision: number; - recall: number; - f1_score: number; - }; -} - -export interface MLEngineHealthResponse { - status: 'healthy' | 'unhealthy'; - version: string; - models_loaded: number; - gpu_available: boolean; - uptime_seconds: number; -} - -// ============================================================================ -// Client Implementation -// ============================================================================ - -class MLEngineClient { - private client: AxiosInstance; - private baseUrl: string; - - constructor() { - this.baseUrl = process.env.ML_ENGINE_URL || 'http://localhost:8001'; - - this.client = axios.create({ - baseURL: this.baseUrl, - timeout: 60000, // ML operations can be slow - headers: { - 'Content-Type': 'application/json', - }, - }); - - // Request interceptor - this.client.interceptors.request.use( - (config) => { - logger.debug('[MLEngineClient] Request:', { - method: config.method?.toUpperCase(), - url: config.url, - }); - return config; - }, - (error) => { - logger.error('[MLEngineClient] Request error:', error.message); - return Promise.reject(error); - } - ); - - // Response interceptor - this.client.interceptors.response.use( - (response) => response, - (error: AxiosError) => { - const status = error.response?.status; - const message = (error.response?.data as { detail?: string })?.detail || error.message; - - logger.error('[MLEngineClient] Response error:', { - status, - message, - url: error.config?.url, - }); - - throw new Error(`ML Engine API error: ${message}`); - } - ); - } - - // ========================================================================== - // Health & Status - // ========================================================================== - - /** - * Check if ML Engine service is healthy - */ - async healthCheck(): Promise { - const response = await this.client.get('/health'); - return response.data; - } - - /** - * Check if service is available - */ - async isAvailable(): Promise { - try { - await this.healthCheck(); - return true; - } catch { - return false; - } - } - - /** - * Get loaded models information - */ - async getModels(): Promise { - const response = await this.client.get<{ models: ModelInfo[] }>('/api/v1/models'); - return response.data.models; - } - - // ========================================================================== - // Signals & Predictions - // ========================================================================== - - /** - * Get ML signal for a symbol - */ - async getSignal(request: SignalRequest): Promise { - const response = await this.client.post('/api/v1/signals/predict', request); - - logger.debug('[MLEngineClient] Signal generated:', { - symbol: response.data.symbol, - direction: response.data.direction, - confidence: response.data.confidence, - }); - - return response.data; - } - - /** - * Get signals for multiple symbols - */ - async getSignalsBatch(request: BatchSignalRequest): Promise> { - const response = await this.client.post<{ signals: Record }>( - '/api/v1/signals/batch', - request - ); - return response.data.signals; - } - - /** - * Get latest signal for a symbol (cached) - */ - async getLatestSignal(symbol: string): Promise { - try { - const response = await this.client.get(`/api/v1/signals/latest/${symbol}`); - return response.data; - } catch (error) { - if ((error as AxiosError).response?.status === 404) { - return null; - } - throw error; - } - } - - /** - * Get range prediction (delta high/low) - */ - async getRangePrediction(symbol: string, timeframe: Timeframe = '15m'): Promise { - const response = await this.client.get( - `/api/v1/predictions/range/${symbol}`, - { params: { timeframe } } - ); - return response.data; - } - - // ========================================================================== - // AMD Analysis - // ========================================================================== - - /** - * Get AMD phase analysis for a symbol - */ - async getAMDAnalysis(symbol: string, timeframe: Timeframe = '15m'): Promise { - const response = await this.client.get( - `/api/v1/amd/analyze/${symbol}`, - { params: { timeframe } } - ); - - logger.debug('[MLEngineClient] AMD Analysis:', { - symbol, - phase: response.data.current_phase, - confidence: response.data.phase_confidence, - }); - - return response.data; - } - - /** - * Get AMD analysis for multiple symbols - */ - async getAMDAnalysisBatch( - symbols: string[], - timeframe: Timeframe = '15m' - ): Promise> { - const response = await this.client.post<{ analyses: Record }>( - '/api/v1/amd/batch', - { symbols, timeframe } - ); - return response.data.analyses; - } - - // ========================================================================== - // Backtesting - // ========================================================================== - - /** - * Run backtest for a symbol/strategy - */ - async runBacktest(request: BacktestRequest): Promise { - const response = await this.client.post('/api/v1/backtest/run', request); - - logger.info('[MLEngineClient] Backtest completed:', { - symbol: request.symbol, - trades: response.data.metrics.total_trades, - winRate: response.data.metrics.win_rate, - return: response.data.metrics.total_return_percent, - }); - - return response.data; - } - - /** - * Get backtest status (for long-running backtests) - */ - async getBacktestStatus(jobId: string): Promise<{ status: string; progress: number }> { - const response = await this.client.get<{ status: string; progress: number }>( - `/api/v1/backtest/status/${jobId}` - ); - return response.data; - } - - // ========================================================================== - // Training (Admin only) - // ========================================================================== - - /** - * Trigger model retraining - */ - async triggerTraining( - symbol: string, - options?: { timeframe?: Timeframe; force?: boolean } - ): Promise<{ job_id: string; estimated_time: string }> { - const response = await this.client.post<{ job_id: string; estimated_time: string }>( - '/api/v1/train/start', - { symbol, ...options } - ); - - logger.info('[MLEngineClient] Training triggered:', { - symbol, - jobId: response.data.job_id, - }); - - return response.data; - } - - /** - * Get training status - */ - async getTrainingStatus(jobId: string): Promise<{ - status: 'pending' | 'running' | 'completed' | 'failed'; - progress: number; - metrics?: Record; - error?: string; - }> { - const response = await this.client.get(`/api/v1/train/status/${jobId}`); - return response.data; - } - - // ========================================================================== - // WebSocket Connection Info - // ========================================================================== - - /** - * Get WebSocket URL for real-time signals - */ - getSignalsWebSocketUrl(): string { - const wsProtocol = this.baseUrl.startsWith('https') ? 'wss' : 'ws'; - const wsUrl = this.baseUrl.replace(/^https?/, wsProtocol); - return `${wsUrl}/ws/signals`; - } -} - -// Export singleton instance -export const mlEngineClient = new MLEngineClient(); diff --git a/apps/backend/src/shared/clients/trading-agents.client.ts b/apps/backend/src/shared/clients/trading-agents.client.ts deleted file mode 100644 index 59d0398..0000000 --- a/apps/backend/src/shared/clients/trading-agents.client.ts +++ /dev/null @@ -1,363 +0,0 @@ -/** - * Trading Agents Client - * Client for communicating with the Trading Agents Python service - * - * @see apps/trading-agents/INTEGRATION.md - */ - -import axios, { AxiosInstance, AxiosError } from 'axios'; -import { logger } from '../utils/logger'; - -// ============================================================================ -// Types -// ============================================================================ - -export type AgentType = 'atlas' | 'orion' | 'nova'; -export type AgentStatus = 'stopped' | 'starting' | 'running' | 'paused' | 'error'; - -export interface AgentConfig { - name: string; - initial_equity: number; - symbols?: string[]; - risk_per_trade?: number; - max_positions?: number; -} - -export interface AgentStatusResponse { - agent_name: string; - status: AgentStatus; - equity: number; - positions: number; - today_pnl: number; - uptime_seconds: number; - last_trade_at?: string; - error_message?: string; -} - -export interface AgentMetrics { - total_trades: number; - winning_trades: number; - losing_trades: number; - win_rate: number; - total_profit: number; - total_loss: number; - net_pnl: number; - max_drawdown: number; - current_drawdown: number; - sharpe_ratio?: number; - sortino_ratio?: number; - profit_factor?: number; -} - -export interface AgentPosition { - id: string; - symbol: string; - side: 'long' | 'short'; - quantity: number; - entry_price: number; - current_price: number; - unrealized_pnl: number; - unrealized_pnl_percent: number; - stop_loss?: number; - take_profit?: number; - opened_at: string; -} - -export interface AgentTrade { - id: string; - symbol: string; - side: 'long' | 'short'; - quantity: number; - entry_price: number; - exit_price: number; - pnl: number; - pnl_percent: number; - opened_at: string; - closed_at: string; - close_reason: string; -} - -export interface SignalInput { - symbol: string; - action: 'buy' | 'sell' | 'hold'; - confidence: number; - price: number; - stop_loss?: number; - take_profit?: number; - metadata?: Record; -} - -export interface TradingAgentsHealthResponse { - status: 'healthy' | 'unhealthy'; - version: string; - agents_running: number; - uptime_seconds: number; -} - -// ============================================================================ -// Client Implementation -// ============================================================================ - -class TradingAgentsClient { - private client: AxiosInstance; - private baseUrl: string; - - constructor() { - this.baseUrl = process.env.TRADING_AGENTS_URL || 'http://localhost:8004'; - - this.client = axios.create({ - baseURL: this.baseUrl, - timeout: 30000, - headers: { - 'Content-Type': 'application/json', - }, - }); - - // Request interceptor for logging - this.client.interceptors.request.use( - (config) => { - logger.debug('[TradingAgentsClient] Request:', { - method: config.method?.toUpperCase(), - url: config.url, - }); - return config; - }, - (error) => { - logger.error('[TradingAgentsClient] Request error:', error.message); - return Promise.reject(error); - } - ); - - // Response interceptor for error handling - this.client.interceptors.response.use( - (response) => response, - (error: AxiosError) => { - const status = error.response?.status; - const message = (error.response?.data as { detail?: string })?.detail || error.message; - - logger.error('[TradingAgentsClient] Response error:', { - status, - message, - url: error.config?.url, - }); - - throw new Error(`Trading Agents API error: ${message}`); - } - ); - } - - // ========================================================================== - // Health & Status - // ========================================================================== - - /** - * Check if Trading Agents service is healthy - */ - async healthCheck(): Promise { - const response = await this.client.get('/health'); - return response.data; - } - - /** - * Check if service is available (simple ping) - */ - async isAvailable(): Promise { - try { - await this.healthCheck(); - return true; - } catch { - return false; - } - } - - // ========================================================================== - // Agent Lifecycle - // ========================================================================== - - /** - * Start a trading agent - */ - async startAgent(agentType: AgentType, config: AgentConfig): Promise { - const response = await this.client.post( - `/api/v1/agents/${agentType}/start`, - config - ); - - logger.info('[TradingAgentsClient] Agent started:', { - agent: agentType, - equity: config.initial_equity, - }); - - return response.data; - } - - /** - * Stop a trading agent - */ - async stopAgent(agentType: AgentType): Promise { - const response = await this.client.post( - `/api/v1/agents/${agentType}/stop` - ); - - logger.info('[TradingAgentsClient] Agent stopped:', { agent: agentType }); - - return response.data; - } - - /** - * Pause a trading agent (keeps positions, stops new trades) - */ - async pauseAgent(agentType: AgentType): Promise { - const response = await this.client.post( - `/api/v1/agents/${agentType}/pause` - ); - - logger.info('[TradingAgentsClient] Agent paused:', { agent: agentType }); - - return response.data; - } - - /** - * Resume a paused trading agent - */ - async resumeAgent(agentType: AgentType): Promise { - const response = await this.client.post( - `/api/v1/agents/${agentType}/resume` - ); - - logger.info('[TradingAgentsClient] Agent resumed:', { agent: agentType }); - - return response.data; - } - - // ========================================================================== - // Agent Status & Metrics - // ========================================================================== - - /** - * Get current status of an agent - */ - async getAgentStatus(agentType: AgentType): Promise { - const response = await this.client.get( - `/api/v1/agents/${agentType}/status` - ); - return response.data; - } - - /** - * Get performance metrics of an agent - */ - async getAgentMetrics(agentType: AgentType): Promise { - const response = await this.client.get( - `/api/v1/agents/${agentType}/metrics` - ); - return response.data; - } - - /** - * Get all running agents status - */ - async getAllAgentsStatus(): Promise> { - const response = await this.client.get>( - '/api/v1/agents/status' - ); - return response.data; - } - - // ========================================================================== - // Positions & Trades - // ========================================================================== - - /** - * Get open positions for an agent - */ - async getPositions(agentType: AgentType): Promise { - const response = await this.client.get<{ positions: AgentPosition[] }>( - `/api/v1/agents/${agentType}/positions` - ); - return response.data.positions; - } - - /** - * Get trade history for an agent - */ - async getTrades( - agentType: AgentType, - options?: { limit?: number; offset?: number; symbol?: string } - ): Promise { - const response = await this.client.get<{ trades: AgentTrade[] }>( - `/api/v1/agents/${agentType}/trades`, - { params: options } - ); - return response.data.trades; - } - - /** - * Close a specific position - */ - async closePosition(agentType: AgentType, positionId: string): Promise { - const response = await this.client.post( - `/api/v1/agents/${agentType}/positions/${positionId}/close` - ); - - logger.info('[TradingAgentsClient] Position closed:', { - agent: agentType, - positionId, - }); - - return response.data; - } - - /** - * Close all positions for an agent - */ - async closeAllPositions(agentType: AgentType): Promise<{ closed: number }> { - const response = await this.client.post<{ closed: number }>( - `/api/v1/agents/${agentType}/positions/close-all` - ); - - logger.info('[TradingAgentsClient] All positions closed:', { - agent: agentType, - closed: response.data.closed, - }); - - return response.data; - } - - // ========================================================================== - // Signal Handling - // ========================================================================== - - /** - * Send a trading signal to an agent - */ - async sendSignal(agentType: AgentType, signal: SignalInput): Promise<{ received: boolean }> { - const response = await this.client.post<{ received: boolean }>( - `/api/v1/agents/${agentType}/signal`, - signal - ); - - logger.debug('[TradingAgentsClient] Signal sent:', { - agent: agentType, - symbol: signal.symbol, - action: signal.action, - confidence: signal.confidence, - }); - - return response.data; - } - - /** - * Broadcast signal to all running agents - */ - async broadcastSignal(signal: SignalInput): Promise<{ agents_notified: number }> { - const response = await this.client.post<{ agents_notified: number }>( - '/api/v1/signals/broadcast', - signal - ); - return response.data; - } -} - -// Export singleton instance -export const tradingAgentsClient = new TradingAgentsClient(); diff --git a/apps/backend/src/shared/constants/database.constants.ts b/apps/backend/src/shared/constants/database.constants.ts deleted file mode 100644 index d5d5735..0000000 --- a/apps/backend/src/shared/constants/database.constants.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Database Constants - * Schema names and table mappings for OrbiQuant IA - */ - -// Database Schemas -export const DB_SCHEMAS = { - PUBLIC: 'public', - EDUCATION: 'education', - TRADING: 'trading', - FINANCIAL: 'financial', - AUDIT: 'audit', -} as const; - -// Table Names with full schema paths -export const DB_TABLES = { - // Public Schema - Users & Auth - USERS: 'users', - PROFILES: 'profiles', - USER_SETTINGS: 'user_settings', - OAUTH_ACCOUNTS: 'oauth_accounts', - SESSIONS: 'sessions', - EMAIL_VERIFICATIONS: 'email_verifications', - PHONE_VERIFICATIONS: 'phone_verifications', - AUTH_LOGS: 'auth_logs', - - // Education Schema - COURSES: `${DB_SCHEMAS.EDUCATION}.courses`, - COURSE_MODULES: `${DB_SCHEMAS.EDUCATION}.course_modules`, - LESSONS: `${DB_SCHEMAS.EDUCATION}.lessons`, - ENROLLMENTS: `${DB_SCHEMAS.EDUCATION}.enrollments`, - LESSON_PROGRESS: `${DB_SCHEMAS.EDUCATION}.lesson_progress`, - COURSE_PROGRESS: `${DB_SCHEMAS.EDUCATION}.course_progress`, - QUIZZES: `${DB_SCHEMAS.EDUCATION}.quizzes`, - QUIZ_QUESTIONS: `${DB_SCHEMAS.EDUCATION}.quiz_questions`, - QUIZ_ATTEMPTS: `${DB_SCHEMAS.EDUCATION}.quiz_attempts`, - CERTIFICATES: `${DB_SCHEMAS.EDUCATION}.certificates`, - - // Trading Schema - BOTS: `${DB_SCHEMAS.TRADING}.bots`, - BOT_SUBSCRIPTIONS: `${DB_SCHEMAS.TRADING}.bot_subscriptions`, - BOT_REVIEWS: `${DB_SCHEMAS.TRADING}.bot_reviews`, - TRADING_SIGNALS: `${DB_SCHEMAS.TRADING}.trading_signals`, - SIGNAL_EXECUTIONS: `${DB_SCHEMAS.TRADING}.signal_executions`, - PORTFOLIOS: `${DB_SCHEMAS.TRADING}.portfolios`, - POSITIONS: `${DB_SCHEMAS.TRADING}.positions`, - TRADE_HISTORY: `${DB_SCHEMAS.TRADING}.trade_history`, - - // Financial Schema - TRANSACTIONS: `${DB_SCHEMAS.FINANCIAL}.transactions`, - SUBSCRIPTIONS: `${DB_SCHEMAS.FINANCIAL}.subscriptions`, - INVOICES: `${DB_SCHEMAS.FINANCIAL}.invoices`, - CREDIT_BALANCES: `${DB_SCHEMAS.FINANCIAL}.credit_balances`, - CREDIT_TRANSACTIONS: `${DB_SCHEMAS.FINANCIAL}.credit_transactions`, - PAYMENT_METHODS: `${DB_SCHEMAS.FINANCIAL}.payment_methods`, - - // Audit Schema - AUDIT_LOGS: `${DB_SCHEMAS.AUDIT}.audit_logs`, - SECURITY_EVENTS: `${DB_SCHEMAS.AUDIT}.security_events`, -} as const; - -export type DbSchema = (typeof DB_SCHEMAS)[keyof typeof DB_SCHEMAS]; -export type DbTable = (typeof DB_TABLES)[keyof typeof DB_TABLES]; diff --git a/apps/backend/src/shared/constants/enums.constants.ts b/apps/backend/src/shared/constants/enums.constants.ts deleted file mode 100644 index 0332362..0000000 --- a/apps/backend/src/shared/constants/enums.constants.ts +++ /dev/null @@ -1,183 +0,0 @@ -/** - * Shared Enums - * Type-safe enums for OrbiQuant IA - */ - -// User Status -export enum UserStatusEnum { - PENDING = 'pending', - ACTIVE = 'active', - INACTIVE = 'inactive', - BANNED = 'banned', -} - -// User Roles -export enum UserRoleEnum { - USER = 'user', - INSTRUCTOR = 'instructor', - ADMIN = 'admin', - SUPER_ADMIN = 'super_admin', -} - -// Auth Providers -export enum AuthProviderEnum { - EMAIL = 'email', - PHONE = 'phone', - GOOGLE = 'google', - FACEBOOK = 'facebook', - TWITTER = 'twitter', - APPLE = 'apple', - GITHUB = 'github', -} - -// Phone Channel -export enum PhoneChannelEnum { - SMS = 'sms', - WHATSAPP = 'whatsapp', -} - -// Course Status -export enum CourseStatusEnum { - DRAFT = 'draft', - PUBLISHED = 'published', - ARCHIVED = 'archived', -} - -// Course Level -export enum CourseLevelEnum { - BEGINNER = 'beginner', - INTERMEDIATE = 'intermediate', - ADVANCED = 'advanced', -} - -// Enrollment Status -export enum EnrollmentStatusEnum { - ACTIVE = 'active', - COMPLETED = 'completed', - CANCELLED = 'cancelled', - EXPIRED = 'expired', -} - -// Bot Status -export enum BotStatusEnum { - DRAFT = 'draft', - ACTIVE = 'active', - PAUSED = 'paused', - ARCHIVED = 'archived', -} - -// Bot Strategy Type -export enum BotStrategyTypeEnum { - SCALPING = 'scalping', - SWING = 'swing', - POSITION = 'position', - ARBITRAGE = 'arbitrage', - TREND_FOLLOWING = 'trend_following', - MEAN_REVERSION = 'mean_reversion', -} - -// Risk Level -export enum RiskLevelEnum { - LOW = 'low', - MEDIUM = 'medium', - HIGH = 'high', -} - -// Signal Type -export enum SignalTypeEnum { - BUY = 'buy', - SELL = 'sell', - CLOSE = 'close', -} - -// Signal Status -export enum SignalStatusEnum { - PENDING = 'pending', - ACTIVE = 'active', - EXECUTED = 'executed', - EXPIRED = 'expired', - CANCELLED = 'cancelled', -} - -// Position Status -export enum PositionStatusEnum { - OPEN = 'open', - CLOSED = 'closed', - PENDING = 'pending', -} - -// Transaction Type -export enum TransactionTypeEnum { - PAYMENT = 'payment', - REFUND = 'refund', - SUBSCRIPTION = 'subscription', - CREDIT_PURCHASE = 'credit_purchase', - CREDIT_USAGE = 'credit_usage', - BOT_SUBSCRIPTION = 'bot_subscription', -} - -// Transaction Status -export enum TransactionStatusEnum { - PENDING = 'pending', - COMPLETED = 'completed', - FAILED = 'failed', - REFUNDED = 'refunded', - CANCELLED = 'cancelled', -} - -// Subscription Status -export enum SubscriptionStatusEnum { - ACTIVE = 'active', - CANCELLED = 'cancelled', - EXPIRED = 'expired', - PAST_DUE = 'past_due', - PAUSED = 'paused', - TRIALING = 'trialing', -} - -// Subscription Interval -export enum SubscriptionIntervalEnum { - MONTHLY = 'monthly', - QUARTERLY = 'quarterly', - YEARLY = 'yearly', -} - -// Audit Event Types -export enum AuditEventTypeEnum { - // Auth events - LOGIN = 'login', - LOGOUT = 'logout', - REGISTER = 'register', - PASSWORD_CHANGE = 'password_change', - PASSWORD_RESET = 'password_reset', - EMAIL_VERIFIED = 'email_verified', - PHONE_VERIFIED = 'phone_verified', - TWO_FA_ENABLED = 'two_fa_enabled', - TWO_FA_DISABLED = 'two_fa_disabled', - OAUTH_LINKED = 'oauth_linked', - OAUTH_UNLINKED = 'oauth_unlinked', - - // User events - PROFILE_UPDATED = 'profile_updated', - SETTINGS_UPDATED = 'settings_updated', - - // Course events - COURSE_ENROLLED = 'course_enrolled', - COURSE_COMPLETED = 'course_completed', - LESSON_COMPLETED = 'lesson_completed', - CERTIFICATE_ISSUED = 'certificate_issued', - - // Trading events - BOT_SUBSCRIBED = 'bot_subscribed', - BOT_UNSUBSCRIBED = 'bot_unsubscribed', - SIGNAL_EXECUTED = 'signal_executed', - POSITION_OPENED = 'position_opened', - POSITION_CLOSED = 'position_closed', - - // Payment events - PAYMENT_COMPLETED = 'payment_completed', - PAYMENT_FAILED = 'payment_failed', - SUBSCRIPTION_CREATED = 'subscription_created', - SUBSCRIPTION_CANCELLED = 'subscription_cancelled', - CREDITS_PURCHASED = 'credits_purchased', -} diff --git a/apps/backend/src/shared/constants/index.ts b/apps/backend/src/shared/constants/index.ts deleted file mode 100644 index 7261af0..0000000 --- a/apps/backend/src/shared/constants/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Shared Constants - Barrel Export - * - * @usage import { DB_SCHEMAS, UserStatusEnum, API_ROUTES } from '@/shared/constants'; - */ - -// Database constants -export * from './database.constants'; - -// Enum constants -export * from './enums.constants'; - -// Route constants -export * from './routes.constants'; diff --git a/apps/backend/src/shared/constants/routes.constants.ts b/apps/backend/src/shared/constants/routes.constants.ts deleted file mode 100644 index 6b11a71..0000000 --- a/apps/backend/src/shared/constants/routes.constants.ts +++ /dev/null @@ -1,159 +0,0 @@ -/** - * API Routes Constants - * Centralized route definitions for OrbiQuant IA - */ - -export const API_PREFIX = 'api'; -export const API_VERSION = 'v1'; - -export const API_ROUTES = { - // Auth routes - AUTH: { - BASE: '/auth', - REGISTER: '/register', - LOGIN: '/login', - LOGOUT: '/logout', - REFRESH: '/refresh', - ME: '/me', - VERIFY_EMAIL: '/verify-email', - RESEND_VERIFICATION: '/resend-verification', - FORGOT_PASSWORD: '/forgot-password', - RESET_PASSWORD: '/reset-password', - CHANGE_PASSWORD: '/change-password', - OAUTH: '/oauth/:provider', - OAUTH_URL: '/oauth/:provider/url', - OAUTH_CALLBACK: '/oauth/:provider/callback', - PHONE_SEND: '/phone/send', - PHONE_VERIFY: '/phone/verify', - TWO_FA_SETUP: '/2fa/setup', - TWO_FA_ENABLE: '/2fa/enable', - TWO_FA_VERIFY: '/2fa/verify', - TWO_FA_DISABLE: '/2fa/disable', - TWO_FA_BACKUP_CODES: '/2fa/backup-codes', - SESSIONS: '/sessions', - SESSIONS_ID: '/sessions/:sessionId', - }, - - // Users routes - USERS: { - BASE: '/users', - ID: '/:userId', - PROFILE: '/:userId/profile', - AVATAR: '/:userId/avatar', - SETTINGS: '/:userId/settings', - PREFERENCES: '/:userId/preferences', - }, - - // Education routes - EDUCATION: { - BASE: '/education', - COURSES: '/courses', - COURSES_FEATURED: '/courses/featured', - COURSES_SEARCH: '/courses/search', - COURSES_ID: '/courses/:courseId', - COURSES_SLUG: '/courses/slug/:slug', - COURSES_MODULES: '/courses/:courseId/modules', - COURSES_ENROLL: '/courses/:courseId/enroll', - COURSES_PROGRESS: '/courses/:courseId/progress', - LESSONS: '/lessons/:lessonId', - LESSONS_COMPLETE: '/lessons/:lessonId/complete', - QUIZZES: '/quizzes/:quizId', - QUIZZES_SUBMIT: '/quizzes/:quizId/submit', - CERTIFICATES: '/certificates', - CERTIFICATES_ID: '/certificates/:certificateId', - CERTIFICATES_VERIFY: '/certificates/verify/:code', - MY_COURSES: '/my-courses', - }, - - // Trading routes - TRADING: { - BASE: '/trading', - BOTS: '/bots', - BOTS_FEATURED: '/bots/featured', - BOTS_ID: '/bots/:botId', - BOTS_SUBSCRIBE: '/bots/:botId/subscribe', - BOTS_REVIEWS: '/bots/:botId/reviews', - SIGNALS: '/signals', - SIGNALS_ID: '/signals/:signalId', - SIGNALS_EXECUTE: '/signals/:signalId/execute', - PORTFOLIO: '/portfolio', - POSITIONS: '/positions', - POSITIONS_ID: '/positions/:positionId', - POSITIONS_CLOSE: '/positions/:positionId/close', - HISTORY: '/history', - MY_SUBSCRIPTIONS: '/my-subscriptions', - }, - - // Investment routes - INVESTMENT: { - BASE: '/investment', - ACCOUNTS: '/accounts', - ACCOUNTS_ID: '/accounts/:accountId', - ACCOUNTS_CONNECT: '/accounts/connect', - PERFORMANCE: '/performance', - ANALYTICS: '/analytics', - }, - - // Payments routes - PAYMENTS: { - BASE: '/payments', - LIST: '/', - ID: '/:paymentId', - CHECKOUT: '/checkout', - CREDITS: '/credits', - CREDITS_PURCHASE: '/credits/purchase', - SUBSCRIPTIONS: '/subscriptions', - SUBSCRIPTIONS_ID: '/subscriptions/:subscriptionId', - SUBSCRIPTIONS_CANCEL: '/subscriptions/:subscriptionId/cancel', - INVOICES: '/invoices', - INVOICES_ID: '/invoices/:invoiceId', - INVOICES_DOWNLOAD: '/invoices/:invoiceId/download', - PAYMENT_METHODS: '/payment-methods', - PAYMENT_METHODS_ID: '/payment-methods/:methodId', - WEBHOOK_STRIPE: '/webhooks/stripe', - }, - - // Admin routes - ADMIN: { - BASE: '/admin', - DASHBOARD: '/dashboard', - USERS: '/users', - USERS_ID: '/users/:userId', - COURSES: '/courses', - COURSES_ID: '/courses/:courseId', - BOTS: '/bots', - BOTS_ID: '/bots/:botId', - TRANSACTIONS: '/transactions', - ANALYTICS: '/analytics', - SETTINGS: '/settings', - }, - - // Health check - HEALTH: '/health', -} as const; - -// HTTP Methods -export const HTTP_METHODS = { - GET: 'GET', - POST: 'POST', - PUT: 'PUT', - PATCH: 'PATCH', - DELETE: 'DELETE', - OPTIONS: 'OPTIONS', -} as const; - -// Common HTTP Status Codes -export const HTTP_STATUS = { - OK: 200, - CREATED: 201, - NO_CONTENT: 204, - BAD_REQUEST: 400, - UNAUTHORIZED: 401, - FORBIDDEN: 403, - NOT_FOUND: 404, - CONFLICT: 409, - UNPROCESSABLE_ENTITY: 422, - TOO_MANY_REQUESTS: 429, - INTERNAL_SERVER_ERROR: 500, - SERVICE_UNAVAILABLE: 503, -} as const; diff --git a/apps/backend/src/shared/database/index.ts b/apps/backend/src/shared/database/index.ts deleted file mode 100644 index 83e9b97..0000000 --- a/apps/backend/src/shared/database/index.ts +++ /dev/null @@ -1,110 +0,0 @@ -// ============================================================================ -// OrbiQuant IA - Database Connection -// ============================================================================ - -import { Pool, PoolClient, QueryResult, QueryResultRow } from 'pg'; -import { config } from '../../config'; -import { logger } from '../utils/logger'; - -class Database { - private pool: Pool; - - constructor() { - this.pool = new Pool({ - host: config.database.host, - port: config.database.port, - database: config.database.name, - user: config.database.user, - password: config.database.password, - max: config.database.poolMax, - idleTimeoutMillis: config.database.idleTimeout, - connectionTimeoutMillis: config.database.connectionTimeout, - }); - - this.pool.on('connect', () => { - logger.debug('New database connection established'); - }); - - this.pool.on('error', (err) => { - logger.error('Unexpected database error', { error: err.message }); - }); - } - - async query( - text: string, - params?: (string | number | boolean | null | undefined | Date | object)[] - ): Promise> { - const start = Date.now(); - - try { - const result = await this.pool.query(text, params); - const duration = Date.now() - start; - - if (duration > 1000) { - logger.warn('Slow query detected', { - duration: `${duration}ms`, - query: text.slice(0, 100), - }); - } - - return result; - } catch (error) { - logger.error('Database query error', { - error: (error as Error).message, - query: text.slice(0, 100), - }); - throw error; - } - } - - async getClient(): Promise { - const client = await this.pool.connect(); - return client; - } - - async transaction( - callback: (client: PoolClient) => Promise - ): Promise { - const client = await this.pool.connect(); - - try { - await client.query('BEGIN'); - const result = await callback(client); - await client.query('COMMIT'); - return result; - } catch (error) { - await client.query('ROLLBACK'); - throw error; - } finally { - client.release(); - } - } - - async healthCheck(): Promise { - try { - const result = await this.pool.query('SELECT 1'); - return result.rows.length > 0; - } catch { - return false; - } - } - - async close(): Promise { - await this.pool.end(); - logger.info('Database pool closed'); - } - - getPoolStatus(): { - total: number; - idle: number; - waiting: number; - } { - return { - total: this.pool.totalCount, - idle: this.pool.idleCount, - waiting: this.pool.waitingCount, - }; - } -} - -export const db = new Database(); diff --git a/apps/backend/src/shared/factories/MIGRATION_GUIDE.md b/apps/backend/src/shared/factories/MIGRATION_GUIDE.md deleted file mode 100644 index e4b3241..0000000 --- a/apps/backend/src/shared/factories/MIGRATION_GUIDE.md +++ /dev/null @@ -1,452 +0,0 @@ -# Guía de Migración a DIP con ServiceFactory - -Esta guía proporciona pasos detallados para migrar los servicios singleton existentes a un patrón de Dependency Injection usando las interfaces DIP y ServiceFactory. - -## Índice - -1. [Visión General](#visión-general) -2. [Paso a Paso](#paso-a-paso) -3. [Ejemplos Prácticos](#ejemplos-prácticos) -4. [Orden de Migración Recomendado](#orden-de-migración-recomendado) -5. [Testing](#testing) - -## Visión General - -### Antes (Singleton Pattern) -```typescript -// service.ts -export class MyService { - doSomething() { ... } -} -export const myService = new MyService(); - -// consumer.ts -import { myService } from './service'; -myService.doSomething(); -``` - -### Después (DIP + Dependency Injection) -```typescript -// interfaces/my-service.interface.ts -export interface IMyService { - doSomething(): void; -} - -// service.ts -export class MyService implements IMyService { - doSomething() { ... } -} -export const myService = new MyService(); - -// app initialization (index.ts) -ServiceFactory.register(ServiceKeys.MY_SERVICE, myService); - -// consumer.ts -import { ServiceFactory, ServiceKeys } from '@/shared/factories'; -import type { IMyService } from '@/shared/interfaces'; - -const myService = ServiceFactory.getRequired(ServiceKeys.MY_SERVICE); -myService.doSomething(); -``` - -## Paso a Paso - -### 1. Identificar Interface Existente o Crear Nueva - -Verifica si ya existe una interface en `/shared/interfaces/`: -- `ICache` → Para servicios de caché -- `IHttpClient` → Para clientes HTTP -- `ITokenService`, `IEmailService`, etc. → Para servicios de autenticación -- `IBinanceService`, `IMarketService` → Para servicios de trading - -Si no existe, créala siguiendo el patrón: - -```typescript -// shared/interfaces/services/my-service.interface.ts -export interface IMyService { - // Declara todos los métodos públicos - myMethod(param: string): Promise; - anotherMethod(): void; -} -``` - -### 2. Implementar Interface en Clase Existente - -```typescript -// modules/mymodule/services/my.service.ts -import type { IMyService } from '@/shared/interfaces'; - -export class MyService implements IMyService { - // Implementación existente - myMethod(param: string): Promise { - // ... - } - - anotherMethod(): void { - // ... - } -} - -// Mantener el singleton por ahora para compatibilidad -export const myService = new MyService(); -``` - -### 3. Agregar Service Key - -Edita `/shared/factories/service.factory.ts` y agrega el key en `ServiceKeys`: - -```typescript -export const ServiceKeys = { - // ... existentes - MY_SERVICE: 'IMyService', -} as const; -``` - -### 4. Registrar en ServiceFactory - -En `/apps/backend/src/index.ts` (o en un archivo de inicialización dedicado): - -```typescript -import { ServiceFactory, ServiceKeys } from './shared/factories'; - -// Importar servicios singleton existentes -import { tokenService } from './modules/auth/services/token.service'; -import { emailService } from './modules/auth/services/email.service'; -import { marketService } from './modules/trading/services/market.service'; -import { cacheService } from './modules/trading/services/cache.service'; -// ... otros servicios - -// Registrar todos los servicios -function registerServices(): void { - // Auth services - ServiceFactory.register(ServiceKeys.TOKEN_SERVICE, tokenService); - ServiceFactory.register(ServiceKeys.EMAIL_SERVICE, emailService); - - // Trading services - ServiceFactory.register(ServiceKeys.MARKET_SERVICE, marketService); - ServiceFactory.register(ServiceKeys.CACHE_SERVICE, cacheService); - - // ... otros servicios -} - -// Llamar durante la inicialización -async function initializeApp() { - registerServices(); - // ... resto de inicialización -} -``` - -### 5. Actualizar Consumidores Gradualmente - -Opción A - En constructores de clases: -```typescript -import { ServiceFactory, ServiceKeys } from '@/shared/factories'; -import type { ITokenService } from '@/shared/interfaces'; - -export class AuthController { - private tokenService: ITokenService; - - constructor() { - this.tokenService = ServiceFactory.getRequired( - ServiceKeys.TOKEN_SERVICE - ); - } - - async login() { - const tokens = await this.tokenService.createSession(...); - // ... - } -} -``` - -Opción B - En funciones/handlers: -```typescript -import { ServiceFactory, ServiceKeys } from '@/shared/factories'; -import type { IMarketService } from '@/shared/interfaces'; - -export async function getMarketData(req: Request, res: Response) { - const marketService = ServiceFactory.getRequired( - ServiceKeys.MARKET_SERVICE - ); - - const data = await marketService.getKlines(...); - res.json(data); -} -``` - -## Ejemplos Prácticos - -### Ejemplo 1: Migrar TokenService - -#### 1. Interface (ya existe en `/shared/interfaces/services/auth.interface.ts`) -```typescript -export interface ITokenService { - generateAccessToken(user: User): string; - createSession(...): Promise<{ session: Session; tokens: AuthTokens }>; - // ... otros métodos -} -``` - -#### 2. Implementar en clase existente -```typescript -// modules/auth/services/token.service.ts -import type { ITokenService } from '@/shared/interfaces'; - -export class TokenService implements ITokenService { - // Implementación existente (sin cambios) -} - -export const tokenService = new TokenService(); -``` - -#### 3. Registrar -```typescript -// index.ts -ServiceFactory.register(ServiceKeys.TOKEN_SERVICE, tokenService); -``` - -#### 4. Usar en controllers -```typescript -// modules/auth/controllers/auth.controller.ts -import { ServiceFactory, ServiceKeys } from '@/shared/factories'; -import type { ITokenService } from '@/shared/interfaces'; - -export class AuthController { - private tokenService: ITokenService; - - constructor() { - this.tokenService = ServiceFactory.getRequired( - ServiceKeys.TOKEN_SERVICE - ); - } -} -``` - -### Ejemplo 2: Migrar CacheService - -#### 1. Interface (ya existe en `/shared/interfaces/cache.interface.ts`) -```typescript -export interface ICache { - get(key: string): T | null; - set(key: string, data: T, ttlSeconds?: number): void; - // ... otros métodos -} -``` - -#### 2. Implementar -```typescript -// modules/trading/services/cache.service.ts -import type { ICache } from '@/shared/interfaces'; - -export class CacheService implements ICache { - // Implementación existente -} - -export const cacheService = new CacheService(60); -export const marketDataCache = new MarketDataCache(); -``` - -#### 3. Registrar -```typescript -// index.ts -ServiceFactory.register(ServiceKeys.CACHE_SERVICE, cacheService); -ServiceFactory.register(ServiceKeys.MARKET_DATA_CACHE, marketDataCache); -``` - -#### 4. Usar en services -```typescript -// modules/trading/services/market.service.ts -import { ServiceFactory, ServiceKeys } from '@/shared/factories'; -import type { ICache } from '@/shared/interfaces'; - -export class MarketService { - private cache: ICache; - - constructor() { - this.cache = ServiceFactory.getRequired(ServiceKeys.MARKET_DATA_CACHE); - } - - async getKlines(...) { - return this.cache.getOrSet(key, () => fetchFromAPI(...), 5); - } -} -``` - -### Ejemplo 3: Migrar BinanceService - -#### 1. Interface (ya existe en `/shared/interfaces/services/trading.interface.ts`) -```typescript -export interface IBinanceService { - getKlines(...): Promise; - get24hrTicker(...): Promise; - // ... otros métodos -} -``` - -#### 2. Implementar -```typescript -// modules/trading/services/binance.service.ts -import type { IBinanceService } from '@/shared/interfaces'; - -export class BinanceService extends EventEmitter implements IBinanceService { - // Implementación existente -} - -export const binanceService = new BinanceService(); -``` - -#### 3. Registrar -```typescript -ServiceFactory.register(ServiceKeys.BINANCE_SERVICE, binanceService); -``` - -#### 4. Usar -```typescript -const binanceService = ServiceFactory.getRequired( - ServiceKeys.BINANCE_SERVICE -); -``` - -## Orden de Migración Recomendado - -1. **Servicios de Infraestructura** (sin dependencias externas) - - `CacheService` - - `Logger` - - Database clients - -2. **Clientes HTTP** (dependen de cache/logger) - - `LLMAgentClient` - - `MLEngineClient` - - `TradingAgentsClient` - -3. **Servicios de Dominio Básicos** - - `TokenService` - - `BinanceService` - -4. **Servicios de Autenticación** (dependen de TokenService) - - `EmailService` - - `OAuthService` - - `TwoFactorService` - - `PhoneService` - -5. **Servicios de Trading** (dependen de BinanceService y Cache) - - `MarketService` - - `WatchlistService` - - `AlertsService` - - `IndicatorsService` - - `PaperTradingService` - -6. **Servicios de Alto Nivel** (dependen de múltiples servicios) - - `PortfolioService` - - `MLIntegrationService` - - `LLMService` - - `AgentsService` - -7. **Controllers** (última migración) - - Actualizar todos los controllers para usar ServiceFactory - -## Testing - -### Test Unitario con Mocks - -```typescript -import { ServiceFactory, ServiceKeys } from '@/shared/factories'; -import type { ITokenService, IEmailService } from '@/shared/interfaces'; -import { AuthController } from './auth.controller'; - -describe('AuthController', () => { - let mockTokenService: jest.Mocked; - let mockEmailService: jest.Mocked; - - beforeEach(() => { - // Crear mocks - mockTokenService = { - generateAccessToken: jest.fn(), - createSession: jest.fn(), - verifyAccessToken: jest.fn(), - // ... otros métodos - } as jest.Mocked; - - mockEmailService = { - register: jest.fn(), - login: jest.fn(), - sendVerificationEmail: jest.fn(), - // ... otros métodos - } as jest.Mocked; - - // Registrar mocks - ServiceFactory.register(ServiceKeys.TOKEN_SERVICE, mockTokenService); - ServiceFactory.register(ServiceKeys.EMAIL_SERVICE, mockEmailService); - }); - - afterEach(() => { - ServiceFactory.clear(); - }); - - it('should create session on login', async () => { - mockEmailService.login.mockResolvedValue({ - user: { id: '1', email: 'test@test.com' } as any, - tokens: { accessToken: 'token', refreshToken: 'refresh' } as any, - }); - - const controller = new AuthController(); - const result = await controller.login({ email: 'test@test.com', password: 'pass' }); - - expect(mockEmailService.login).toHaveBeenCalled(); - expect(result).toBeDefined(); - }); -}); -``` - -### Test de Integración - -```typescript -import { ServiceFactory, ServiceKeys } from '@/shared/factories'; -import { tokenService } from '@/modules/auth/services/token.service'; -import { emailService } from '@/modules/auth/services/email.service'; - -describe('Auth Integration', () => { - beforeAll(() => { - // Usar servicios reales para test de integración - ServiceFactory.register(ServiceKeys.TOKEN_SERVICE, tokenService); - ServiceFactory.register(ServiceKeys.EMAIL_SERVICE, emailService); - }); - - afterAll(() => { - ServiceFactory.clear(); - }); - - it('should complete full authentication flow', async () => { - // Test completo con servicios reales - }); -}); -``` - -## Ventajas de la Migración - -1. **Testabilidad**: Fácil crear mocks y stubs -2. **Mantenibilidad**: Cambios en implementación no afectan consumidores -3. **Flexibilidad**: Intercambiar implementaciones sin tocar código cliente -4. **Organización**: Dependencias explícitas y gestionadas centralmente -5. **Type Safety**: TypeScript valida que las implementaciones cumplan las interfaces - -## Troubleshooting - -### Error: "Service 'XXX' not found in ServiceFactory" -- Solución: Verificar que el servicio esté registrado en la inicialización de la app -- Verificar que el ServiceKey sea correcto - -### Error: "Property 'xxx' does not exist on type 'IService'" -- Solución: Agregar el método faltante a la interface -- Verificar que la clase implemente correctamente la interface - -### Tests fallan con "Cannot read property of undefined" -- Solución: Asegurar que los mocks estén registrados en beforeEach -- Verificar que ServiceFactory.clear() se llame en afterEach - -## Recursos Adicionales - -- [SOLID Principles](https://en.wikipedia.org/wiki/SOLID) -- [Dependency Inversion Principle](https://en.wikipedia.org/wiki/Dependency_inversion_principle) -- [Dependency Injection](https://en.wikipedia.org/wiki/Dependency_injection) -- Interface Documentation: `/shared/interfaces/README.md` diff --git a/apps/backend/src/shared/factories/index.ts b/apps/backend/src/shared/factories/index.ts deleted file mode 100644 index 4c3f0c7..0000000 --- a/apps/backend/src/shared/factories/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Factories Index - * Central export point for dependency injection factories - */ - -export * from './service.factory'; diff --git a/apps/backend/src/shared/factories/service.factory.ts b/apps/backend/src/shared/factories/service.factory.ts deleted file mode 100644 index ec2bb6f..0000000 --- a/apps/backend/src/shared/factories/service.factory.ts +++ /dev/null @@ -1,197 +0,0 @@ -/** - * Service Factory - * Dependency Injection container for managing service instances - * - * This factory implements the Dependency Inversion Principle (DIP) by: - * 1. Allowing services to depend on interfaces rather than concrete implementations - * 2. Providing a central registry for service instances - * 3. Enabling easy testing by allowing mock implementations - * 4. Supporting lazy initialization and singleton pattern - * - * Usage: - * ```typescript - * // Register a service implementation - * ServiceFactory.register('ITokenService', tokenService); - * - * // Retrieve a service - * const tokenService = ServiceFactory.get('ITokenService'); - * - * // For testing, replace with mock - * ServiceFactory.register('ITokenService', mockTokenService); - * ``` - */ - -type ServiceKey = string; -type ServiceInstance = unknown; - -class ServiceFactory { - private static services: Map = new Map(); - private static factories: Map ServiceInstance> = new Map(); - - /** - * Register a service instance - * @param key - Unique service identifier (typically the interface name) - * @param instance - Service implementation - */ - static register(key: ServiceKey, instance: T): void { - this.services.set(key, instance); - } - - /** - * Register a service factory (lazy initialization) - * @param key - Unique service identifier - * @param factory - Function that creates the service instance - */ - static registerFactory(key: ServiceKey, factory: () => T): void { - this.factories.set(key, factory as () => ServiceInstance); - } - - /** - * Get a service instance - * @param key - Service identifier - * @returns Service instance or undefined if not found - */ - static get(key: ServiceKey): T | undefined { - // Check if instance already exists - if (this.services.has(key)) { - return this.services.get(key) as T; - } - - // Check if factory exists and create instance - if (this.factories.has(key)) { - const factory = this.factories.get(key)!; - const instance = factory(); - this.services.set(key, instance); - return instance as T; - } - - return undefined; - } - - /** - * Get a service instance, throw if not found - * @param key - Service identifier - * @returns Service instance - * @throws Error if service not found - */ - static getRequired(key: ServiceKey): T { - const service = this.get(key); - if (!service) { - throw new Error(`Service '${key}' not found in ServiceFactory`); - } - return service; - } - - /** - * Check if a service is registered - * @param key - Service identifier - * @returns true if service or factory exists - */ - static has(key: ServiceKey): boolean { - return this.services.has(key) || this.factories.has(key); - } - - /** - * Remove a service from the registry - * @param key - Service identifier - */ - static unregister(key: ServiceKey): void { - this.services.delete(key); - this.factories.delete(key); - } - - /** - * Clear all registered services (useful for testing) - */ - static clear(): void { - this.services.clear(); - this.factories.clear(); - } - - /** - * Get all registered service keys - */ - static getRegisteredKeys(): string[] { - const keys = new Set([...this.services.keys(), ...this.factories.keys()]); - return Array.from(keys); - } -} - -/** - * Service Keys - Type-safe constants for service identifiers - */ -export const ServiceKeys = { - // Cache services - CACHE_SERVICE: 'ICache', - MARKET_DATA_CACHE: 'IMarketDataCache', - - // HTTP clients - HTTP_CLIENT: 'IHttpClient', - LLM_AGENT_CLIENT: 'ILLMAgentClient', - ML_ENGINE_CLIENT: 'IMLEngineClient', - TRADING_AGENTS_CLIENT: 'ITradingAgentsClient', - - // Auth services - TOKEN_SERVICE: 'ITokenService', - EMAIL_SERVICE: 'IEmailService', - OAUTH_SERVICE: 'IOAuthService', - TWO_FACTOR_SERVICE: 'ITwoFactorService', - PHONE_SERVICE: 'IPhoneService', - - // Trading services - BINANCE_SERVICE: 'IBinanceService', - MARKET_SERVICE: 'IMarketService', - WATCHLIST_SERVICE: 'IWatchlistService', - ALERTS_SERVICE: 'IAlertsService', - INDICATORS_SERVICE: 'IIndicatorsService', - PAPER_TRADING_SERVICE: 'IPaperTradingService', - - // Portfolio services - PORTFOLIO_SERVICE: 'IPortfolioService', - - // Investment services - ACCOUNT_SERVICE: 'IAccountService', - TRANSACTION_SERVICE: 'ITransactionService', - PRODUCT_SERVICE: 'IProductService', - - // Payment services - STRIPE_SERVICE: 'IStripeService', - WALLET_SERVICE: 'IWalletService', - SUBSCRIPTION_SERVICE: 'ISubscriptionService', - - // ML services - ML_INTEGRATION_SERVICE: 'IMLIntegrationService', - ML_OVERLAY_SERVICE: 'IMLOverlayService', - - // LLM services - LLM_SERVICE: 'ILLMService', - - // Education services - COURSE_SERVICE: 'ICourseService', - ENROLLMENT_SERVICE: 'IEnrollmentService', - - // Agent services - AGENTS_SERVICE: 'IAgentsService', -} as const; - -export type ServiceKeyType = (typeof ServiceKeys)[keyof typeof ServiceKeys]; - -/** - * Decorator for automatic service registration - * Usage: - * ```typescript - * @Service(ServiceKeys.TOKEN_SERVICE) - * class TokenService implements ITokenService { - * // ... - * } - * ``` - */ -export function Service(key: ServiceKey) { - return function (constructor: T) { - // Register factory that creates instance - ServiceFactory.registerFactory(key, () => new constructor()); - return constructor; - }; -} - -export { ServiceFactory }; diff --git a/apps/backend/src/shared/interfaces/README.md b/apps/backend/src/shared/interfaces/README.md deleted file mode 100644 index fcbf8aa..0000000 --- a/apps/backend/src/shared/interfaces/README.md +++ /dev/null @@ -1,242 +0,0 @@ -# Dependency Inversion Principle (DIP) Interfaces - -Este directorio contiene las interfaces para implementar el Principio de Inversión de Dependencias (DIP) en el trading-platform backend. - -## Estructura - -``` -interfaces/ -├── cache.interface.ts # Interface para servicios de cache -├── http-client.interface.ts # Interface para clientes HTTP -├── index.ts # Export central de todas las interfaces -└── services/ - ├── auth.interface.ts # Interfaces para servicios de autenticación - └── trading.interface.ts # Interfaces para servicios de trading -``` - -## Propósito - -Las interfaces permiten: - -1. **Desacoplamiento**: Los servicios dependen de abstracciones, no de implementaciones concretas -2. **Testabilidad**: Facilita crear mocks para pruebas unitarias -3. **Mantenibilidad**: Cambios en implementaciones no afectan a los consumidores -4. **Flexibilidad**: Permite cambiar implementaciones sin modificar el código cliente - -## Interfaces Disponibles - -### Core Infrastructure - -#### `ICache` -Interface para operaciones de caché con TTL. - -**Métodos principales:** -- `get(key: string): T | null` -- `set(key: string, data: T, ttlSeconds?: number): void` -- `getOrSet(key: string, fetcher: () => Promise, ttlSeconds?: number): Promise` -- `delete(key: string): boolean` -- `clear(): void` - -**Implementación actual:** `CacheService` y `MarketDataCache` en `/modules/trading/services/cache.service.ts` - -#### `IHttpClient` -Interface para clientes HTTP basados en Axios. - -**Métodos principales:** -- `get(url: string, config?: AxiosRequestConfig): Promise>` -- `post(url: string, data?: unknown, config?: AxiosRequestConfig): Promise>` -- `isAvailable(): Promise` - -**Implementaciones actuales:** -- `LLMAgentClient` en `/shared/clients/llm-agent.client.ts` -- `MLEngineClient` en `/shared/clients/ml-engine.client.ts` -- `TradingAgentsClient` en `/shared/clients/trading-agents.client.ts` - -### Authentication Services - -#### `ITokenService` -Interface para gestión de tokens JWT y sesiones. - -**Métodos principales:** -- `generateAccessToken(user: User): string` -- `verifyAccessToken(token: string): JWTPayload | null` -- `createSession(...): Promise<{ session: Session; tokens: AuthTokens }>` -- `refreshSession(refreshToken: string): Promise` -- `revokeSession(sessionId: string, userId: string): Promise` - -**Implementación actual:** `TokenService` en `/modules/auth/services/token.service.ts` - -#### `IEmailService` -Interface para autenticación por email/password. - -**Métodos principales:** -- `register(data: RegisterEmailRequest, ...): Promise<{ userId: string; message: string }>` -- `login(data: LoginEmailRequest, ...): Promise` -- `sendVerificationEmail(userId: string, email: string): Promise` -- `verifyEmail(token: string): Promise<{ success: boolean; message: string }>` -- `resetPassword(token: string, newPassword: string): Promise<{ message: string }>` - -**Implementación actual:** `EmailService` en `/modules/auth/services/email.service.ts` - -#### `IOAuthService` -Interface para autenticación OAuth (Google, GitHub, etc.). - -**Métodos principales:** -- `getAuthorizationUrl(provider: string, state: string): string` -- `handleCallback(provider: string, code: string, state: string, ...): Promise` - -**Implementación actual:** `OAuthService` en `/modules/auth/services/oauth.service.ts` - -#### `ITwoFactorService` -Interface para autenticación de dos factores (TOTP). - -**Métodos principales:** -- `generateTOTPSecret(userId: string): Promise<{ secret: string; qrCode: string }>` -- `enableTOTP(userId: string, code: string): Promise<{ success: boolean; backupCodes: string[] }>` -- `verifyTOTP(userId: string, code: string): Promise` - -**Implementación actual:** `TwoFactorService` en `/modules/auth/services/twofa.service.ts` - -#### `IPhoneService` -Interface para autenticación por SMS. - -**Métodos principales:** -- `sendVerificationCode(phoneNumber: string): Promise<{ success: boolean }>` -- `verifyPhoneNumber(phoneNumber: string, code: string): Promise<{ success: boolean; token?: string }>` -- `loginWithPhone(phoneNumber: string, code: string): Promise` - -**Implementación actual:** `PhoneService` en `/modules/auth/services/phone.service.ts` - -### Trading Services - -#### `IBinanceService` -Interface para cliente de API de Binance. - -**Métodos principales:** -- `getServerTime(): Promise` -- `getExchangeInfo(symbols?: string[]): Promise` -- `getKlines(symbol: string, interval: Interval, options?): Promise` -- `get24hrTicker(symbol?: string): Promise` -- `getPrice(symbol?: string): Promise<...>` -- `getOrderBook(symbol: string, limit?: number): Promise` -- `subscribeKlines(symbol: string, interval: Interval): void` -- `subscribeTicker(symbol: string): void` - -**Implementación actual:** `BinanceService` en `/modules/trading/services/binance.service.ts` - -#### `IMarketService` -Interface para fachada de datos de mercado. - -**Métodos principales:** -- `initialize(): Promise` -- `getKlines(symbol: string, interval: Interval, options?): Promise` -- `getPrice(symbol: string): Promise` -- `getPrices(symbols?: string[]): Promise` -- `getTicker(symbol: string): Promise` -- `getWatchlist(symbols: string[]): Promise` -- `getSymbolInfo(symbol: string): MarketSymbol | undefined` -- `searchSymbols(query: string, limit?: number): MarketSymbol[]` - -**Implementación actual:** `MarketService` en `/modules/trading/services/market.service.ts` - -## Uso con ServiceFactory - -```typescript -import { ServiceFactory, ServiceKeys } from '@/shared/factories'; -import type { ITokenService } from '@/shared/interfaces'; - -// En inicialización de la aplicación -import { tokenService } from '@/modules/auth/services/token.service'; -ServiceFactory.register(ServiceKeys.TOKEN_SERVICE, tokenService); - -// En los servicios que necesitan dependencias -class AuthController { - private tokenService: ITokenService; - - constructor() { - this.tokenService = ServiceFactory.getRequired(ServiceKeys.TOKEN_SERVICE); - } - - async login(req: Request, res: Response) { - const tokens = await this.tokenService.createSession(...); - // ... - } -} -``` - -## Migración de Singletons - -Para migrar servicios singleton existentes: - -1. **Crear interface** (si no existe) -2. **Implementar interface en clase existente** -3. **Registrar en ServiceFactory** (en app initialization) -4. **Actualizar consumidores** para usar ServiceFactory en lugar de importar singleton directamente - -### Ejemplo de Migración - -**Antes:** -```typescript -// token.service.ts -export class TokenService { ... } -export const tokenService = new TokenService(); - -// auth.controller.ts -import { tokenService } from './token.service'; -``` - -**Después:** -```typescript -// token.service.ts -import type { ITokenService } from '@/shared/interfaces'; - -export class TokenService implements ITokenService { ... } -export const tokenService = new TokenService(); - -// index.ts (app initialization) -ServiceFactory.register(ServiceKeys.TOKEN_SERVICE, tokenService); - -// auth.controller.ts -import { ServiceFactory, ServiceKeys } from '@/shared/factories'; -import type { ITokenService } from '@/shared/interfaces'; - -const tokenService = ServiceFactory.getRequired(ServiceKeys.TOKEN_SERVICE); -``` - -## Testing - -Las interfaces facilitan el testing con mocks: - -```typescript -import { ServiceFactory, ServiceKeys } from '@/shared/factories'; -import type { ITokenService } from '@/shared/interfaces'; - -describe('AuthController', () => { - beforeEach(() => { - // Mock implementation - const mockTokenService: ITokenService = { - generateAccessToken: jest.fn().mockReturnValue('mock-token'), - createSession: jest.fn().mockResolvedValue({ ... }), - // ... otros métodos - }; - - ServiceFactory.register(ServiceKeys.TOKEN_SERVICE, mockTokenService); - }); - - afterEach(() => { - ServiceFactory.clear(); - }); - - it('should create session on login', async () => { - // Test con mock - }); -}); -``` - -## Próximos Pasos - -1. Implementar interfaces en servicios existentes -2. Registrar servicios en ServiceFactory durante la inicialización -3. Actualizar consumidores para usar ServiceFactory -4. Crear tests unitarios con mocks -5. Documentar servicios adicionales según sea necesario diff --git a/apps/backend/src/shared/interfaces/cache.interface.ts b/apps/backend/src/shared/interfaces/cache.interface.ts deleted file mode 100644 index 60c6f2b..0000000 --- a/apps/backend/src/shared/interfaces/cache.interface.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Cache Interface - * Abstraction for caching operations following DIP - */ - -export interface CacheStats { - hits: number; - misses: number; - size: number; - hitRate: number; -} - -export interface ICache { - /** - * Get a value from cache - */ - get(key: string): T | null; - - /** - * Set a value in cache with optional TTL - */ - set(key: string, data: T, ttlSeconds?: number): void; - - /** - * Get or set a value in cache using a fetcher function - */ - getOrSet(key: string, fetcher: () => Promise, ttlSeconds?: number): Promise; - - /** - * Delete a value from cache - */ - delete(key: string): boolean; - - /** - * Delete all values matching a pattern - */ - deletePattern(pattern: string): number; - - /** - * Clear the entire cache - */ - clear(): void; - - /** - * Check if a key exists and is not expired - */ - has(key: string): boolean; - - /** - * Get cache statistics - */ - getStats(): CacheStats; - - /** - * Get all keys in cache - */ - keys(): string[]; - - /** - * Get cache size - */ - size(): number; - - /** - * Refresh TTL for a key - */ - touch(key: string, ttlSeconds?: number): boolean; - - /** - * Get time to live for a key in seconds - */ - ttl(key: string): number | null; - - /** - * Cleanup resources (for graceful shutdown) - */ - destroy(): void; -} diff --git a/apps/backend/src/shared/interfaces/http-client.interface.ts b/apps/backend/src/shared/interfaces/http-client.interface.ts deleted file mode 100644 index d5e8e37..0000000 --- a/apps/backend/src/shared/interfaces/http-client.interface.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * HTTP Client Interface - * Abstraction for HTTP request operations following DIP - */ - -import { AxiosRequestConfig, AxiosResponse } from 'axios'; - -export interface IHttpClient { - /** - * Perform GET request - */ - get(url: string, config?: AxiosRequestConfig): Promise>; - - /** - * Perform POST request - */ - post(url: string, data?: unknown, config?: AxiosRequestConfig): Promise>; - - /** - * Perform PUT request - */ - put(url: string, data?: unknown, config?: AxiosRequestConfig): Promise>; - - /** - * Perform PATCH request - */ - patch(url: string, data?: unknown, config?: AxiosRequestConfig): Promise>; - - /** - * Perform DELETE request - */ - delete(url: string, config?: AxiosRequestConfig): Promise>; - - /** - * Get base URL - */ - getBaseUrl(): string; - - /** - * Check if service is available - */ - isAvailable(): Promise; -} diff --git a/apps/backend/src/shared/interfaces/index.ts b/apps/backend/src/shared/interfaces/index.ts deleted file mode 100644 index ce343e6..0000000 --- a/apps/backend/src/shared/interfaces/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Shared Interfaces Index - * Central export point for all DIP interfaces - */ - -// Core infrastructure interfaces -export * from './http-client.interface'; -export * from './cache.interface'; - -// Service interfaces -export * from './services/auth.interface'; -export * from './services/trading.interface'; diff --git a/apps/backend/src/shared/interfaces/services/auth.interface.ts b/apps/backend/src/shared/interfaces/services/auth.interface.ts deleted file mode 100644 index a774bf8..0000000 --- a/apps/backend/src/shared/interfaces/services/auth.interface.ts +++ /dev/null @@ -1,209 +0,0 @@ -/** - * Authentication Service Interfaces - * Abstraction for authentication operations following DIP - */ - -import type { - User, - Profile, - AuthTokens, - AuthResponse, - Session, - JWTPayload, - JWTRefreshPayload, - RegisterEmailRequest, - LoginEmailRequest, -} from '../../../modules/auth/types/auth.types'; - -/** - * Token Service Interface - */ -export interface ITokenService { - /** - * Generate access token for a user - */ - generateAccessToken(user: User): string; - - /** - * Generate refresh token for a session - */ - generateRefreshToken(userId: string, sessionId: string): string; - - /** - * Verify access token and return payload - */ - verifyAccessToken(token: string): JWTPayload | null; - - /** - * Verify refresh token and return payload - */ - verifyRefreshToken(token: string): JWTRefreshPayload | null; - - /** - * Create a new session with tokens - */ - createSession( - userId: string, - userAgent?: string, - ipAddress?: string, - deviceInfo?: Record - ): Promise<{ session: Session; tokens: AuthTokens }>; - - /** - * Refresh an existing session - */ - refreshSession(refreshToken: string): Promise; - - /** - * Revoke a specific session - */ - revokeSession(sessionId: string, userId: string): Promise; - - /** - * Revoke all user sessions except one - */ - revokeAllUserSessions(userId: string, exceptSessionId?: string): Promise; - - /** - * Get active sessions for a user - */ - getActiveSessions(userId: string): Promise; - - /** - * Generate email verification token - */ - generateEmailToken(): string; - - /** - * Hash a token - */ - hashToken(token: string): string; -} - -/** - * Email Authentication Service Interface - */ -export interface IEmailService { - /** - * Register a new user with email/password - */ - register( - data: RegisterEmailRequest, - userAgent?: string, - ipAddress?: string - ): Promise<{ userId: string; message: string }>; - - /** - * Login with email/password - */ - login( - data: LoginEmailRequest, - userAgent?: string, - ipAddress?: string - ): Promise; - - /** - * Send email verification - */ - sendVerificationEmail(userId: string, email: string): Promise; - - /** - * Verify email with token - */ - verifyEmail(token: string): Promise<{ success: boolean; message: string }>; - - /** - * Send password reset email - */ - sendPasswordResetEmail(email: string): Promise<{ message: string }>; - - /** - * Reset password with token - */ - resetPassword(token: string, newPassword: string): Promise<{ message: string }>; - - /** - * Change password for authenticated user - */ - changePassword( - userId: string, - currentPassword: string, - newPassword: string - ): Promise<{ message: string }>; -} - -/** - * OAuth Service Interface - */ -export interface IOAuthService { - /** - * Generate OAuth authorization URL - */ - getAuthorizationUrl(provider: string, state: string): string; - - /** - * Handle OAuth callback - */ - handleCallback( - provider: string, - code: string, - state: string, - userAgent?: string, - ipAddress?: string - ): Promise; -} - -/** - * Two-Factor Authentication Service Interface - */ -export interface ITwoFactorService { - /** - * Generate TOTP secret - */ - generateTOTPSecret(userId: string): Promise<{ secret: string; qrCode: string }>; - - /** - * Enable TOTP for user - */ - enableTOTP(userId: string, code: string): Promise<{ success: boolean; backupCodes: string[] }>; - - /** - * Disable TOTP for user - */ - disableTOTP(userId: string, password: string): Promise<{ success: boolean }>; - - /** - * Verify TOTP code - */ - verifyTOTP(userId: string, code: string): Promise; - - /** - * Generate backup codes - */ - generateBackupCodes(userId: string): Promise; - - /** - * Verify backup code - */ - verifyBackupCode(userId: string, code: string): Promise; -} - -/** - * Phone Authentication Service Interface - */ -export interface IPhoneService { - /** - * Send verification code to phone - */ - sendVerificationCode(phoneNumber: string): Promise<{ success: boolean }>; - - /** - * Verify phone number with code - */ - verifyPhoneNumber(phoneNumber: string, code: string): Promise<{ success: boolean; token?: string }>; - - /** - * Login with phone number - */ - loginWithPhone(phoneNumber: string, code: string): Promise; -} diff --git a/apps/backend/src/shared/interfaces/services/trading.interface.ts b/apps/backend/src/shared/interfaces/services/trading.interface.ts deleted file mode 100644 index 2b72cf4..0000000 --- a/apps/backend/src/shared/interfaces/services/trading.interface.ts +++ /dev/null @@ -1,442 +0,0 @@ -/** - * Trading Service Interfaces - * Abstraction for trading and market data operations following DIP - */ - -import type { EventEmitter } from 'events'; - -/** - * Intervals for candlestick data - */ -export type Interval = - | '1m' - | '3m' - | '5m' - | '15m' - | '30m' - | '1h' - | '2h' - | '4h' - | '6h' - | '8h' - | '12h' - | '1d' - | '3d' - | '1w' - | '1M'; - -/** - * Candlestick/Kline data structure - */ -export interface Kline { - openTime: number; - open: string; - high: string; - low: string; - close: string; - volume: string; - closeTime: number; - quoteVolume: string; - trades: number; - takerBuyBaseVolume: string; - takerBuyQuoteVolume: string; -} - -/** - * 24h ticker data - */ -export interface Ticker24h { - symbol: string; - priceChange: string; - priceChangePercent: string; - weightedAvgPrice: string; - prevClosePrice: string; - lastPrice: string; - lastQty: string; - bidPrice: string; - bidQty: string; - askPrice: string; - askQty: string; - openPrice: string; - highPrice: string; - lowPrice: string; - volume: string; - quoteVolume: string; - openTime: number; - closeTime: number; - firstId: number; - lastId: number; - count: number; -} - -/** - * Order book entry - */ -export interface OrderBookEntry { - price: string; - quantity: string; -} - -/** - * Order book data - */ -export interface OrderBook { - lastUpdateId: number; - bids: OrderBookEntry[]; - asks: OrderBookEntry[]; -} - -/** - * Symbol information - */ -export interface SymbolInfo { - symbol: string; - status: string; - baseAsset: string; - baseAssetPrecision: number; - quoteAsset: string; - quotePrecision: number; - quoteAssetPrecision: number; - filters: SymbolFilter[]; -} - -/** - * Symbol filter - */ -export interface SymbolFilter { - filterType: string; - minPrice?: string; - maxPrice?: string; - tickSize?: string; - minQty?: string; - maxQty?: string; - stepSize?: string; - minNotional?: string; -} - -/** - * Exchange information - */ -export interface ExchangeInfo { - timezone: string; - serverTime: number; - symbols: SymbolInfo[]; -} - -/** - * Market price data - */ -export interface MarketPrice { - symbol: string; - price: number; - timestamp: number; -} - -/** - * Market ticker data - */ -export interface MarketTicker { - symbol: string; - lastPrice: number; - priceChange: number; - priceChangePercent: number; - high24h: number; - low24h: number; - volume24h: number; - quoteVolume24h: number; -} - -/** - * Candlestick data (transformed) - */ -export interface CandlestickData { - time: number; - open: number; - high: number; - low: number; - close: number; - volume: number; -} - -/** - * Market symbol details - */ -export interface MarketSymbol { - symbol: string; - baseAsset: string; - quoteAsset: string; - status: string; - pricePrecision: number; - quantityPrecision: number; - minPrice: string; - maxPrice: string; - tickSize: string; - minQty: string; - maxQty: string; - stepSize: string; - minNotional: string; -} - -/** - * Watchlist item - */ -export interface WatchlistItem { - symbol: string; - price: number; - change24h: number; - changePercent24h: number; - volume24h: number; - high24h: number; - low24h: number; -} - -/** - * Binance Service Interface (Exchange API Client) - */ -export interface IBinanceService { - /** - * Get server time - */ - getServerTime(): Promise; - - /** - * Get exchange information - */ - getExchangeInfo(symbols?: string[]): Promise; - - /** - * Get klines/candlestick data - */ - getKlines( - symbol: string, - interval: Interval, - options?: { - startTime?: number; - endTime?: number; - limit?: number; - } - ): Promise; - - /** - * Get 24h ticker - */ - get24hrTicker(symbol?: string): Promise; - - /** - * Get current price - */ - getPrice(symbol?: string): Promise<{ symbol: string; price: string } | { symbol: string; price: string }[]>; - - /** - * Get order book - */ - getOrderBook(symbol: string, limit?: number): Promise; - - /** - * Get recent trades - */ - getRecentTrades( - symbol: string, - limit?: number - ): Promise< - { - id: number; - price: string; - qty: string; - quoteQty: string; - time: number; - isBuyerMaker: boolean; - }[] - >; - - /** - * Subscribe to kline stream - */ - subscribeKlines(symbol: string, interval: Interval): void; - - /** - * Subscribe to ticker stream - */ - subscribeTicker(symbol: string): void; - - /** - * Subscribe to all mini tickers - */ - subscribeAllMiniTickers(): void; - - /** - * Subscribe to trade stream - */ - subscribeTrades(symbol: string): void; - - /** - * Subscribe to order book depth stream - */ - subscribeDepth(symbol: string, levels?: 5 | 10 | 20): void; - - /** - * Unsubscribe from stream - */ - unsubscribe(streamName: string): void; - - /** - * Unsubscribe from all streams - */ - unsubscribeAll(): void; - - /** - * Get remaining API requests - */ - getRemainingRequests(): number; - - /** - * Get active WebSocket streams - */ - getActiveStreams(): string[]; - - /** - * Check if stream is active - */ - isStreamActive(streamName: string): boolean; - - /** - * Event emitter methods (inherited from EventEmitter) - */ - on(event: string, listener: (...args: unknown[]) => void): EventEmitter; - emit(event: string, ...args: unknown[]): boolean; -} - -/** - * Market Service Interface (Market Data Facade) - */ -export interface IMarketService { - /** - * Initialize the service - */ - initialize(): Promise; - - /** - * Load exchange information - */ - loadExchangeInfo(): Promise; - - /** - * Get candlestick/kline data - */ - getKlines( - symbol: string, - interval: Interval, - options?: { startTime?: number; endTime?: number; limit?: number } - ): Promise; - - /** - * Get current price for a symbol - */ - getPrice(symbol: string): Promise; - - /** - * Get prices for multiple symbols - */ - getPrices(symbols?: string[]): Promise; - - /** - * Get 24h ticker for a symbol - */ - getTicker(symbol: string): Promise; - - /** - * Get 24h tickers for multiple symbols - */ - getTickers(symbols?: string[]): Promise; - - /** - * Get order book for a symbol - */ - getOrderBook(symbol: string, limit?: number): Promise; - - /** - * Get watchlist data - */ - getWatchlist(symbols: string[]): Promise; - - /** - * Get symbol information - */ - getSymbolInfo(symbol: string): MarketSymbol | undefined; - - /** - * Get all available symbols - */ - getAvailableSymbols(): string[]; - - /** - * Search symbols by query - */ - searchSymbols(query: string, limit?: number): MarketSymbol[]; - - /** - * Get popular symbols - */ - getPopularSymbols(): string[]; - - /** - * Subscribe to real-time kline updates - */ - subscribeKlines(symbol: string, interval: Interval): void; - - /** - * Subscribe to real-time ticker updates - */ - subscribeTicker(symbol: string): void; - - /** - * Subscribe to real-time trade updates - */ - subscribeTrades(symbol: string): void; - - /** - * Subscribe to order book depth updates - */ - subscribeDepth(symbol: string, levels?: 5 | 10 | 20): void; - - /** - * Unsubscribe from a stream - */ - unsubscribe(streamName: string): void; - - /** - * Unsubscribe from all streams - */ - unsubscribeAll(): void; - - /** - * Get active WebSocket streams - */ - getActiveStreams(): string[]; - - /** - * Register kline event handler - */ - onKline( - handler: (data: { symbol: string; interval: string; kline: CandlestickData; isFinal: boolean }) => void - ): void; - - /** - * Register ticker event handler - */ - onTicker(handler: (data: MarketTicker) => void): void; - - /** - * Register trade event handler - */ - onTrade( - handler: (data: { - symbol: string; - tradeId: number; - price: number; - quantity: number; - time: number; - isBuyerMaker: boolean; - }) => void - ): void; -} diff --git a/apps/backend/src/shared/middleware/validate-dto.middleware.ts b/apps/backend/src/shared/middleware/validate-dto.middleware.ts deleted file mode 100644 index f074151..0000000 --- a/apps/backend/src/shared/middleware/validate-dto.middleware.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * DTO Validation Middleware for Express - * - * @description Validates request body against a DTO class using class-validator. - * Returns 400 Bad Request with validation errors if validation fails. - * - * @usage - * ```typescript - * import { validateDto } from '@shared/middleware/validate-dto.middleware'; - * import { LoginDto } from '../dto'; - * - * router.post('/login', validateDto(LoginDto), authController.login); - * ``` - * - * @requires class-validator class-transformer - * ```bash - * npm install class-validator class-transformer - * ``` - */ - -import { Request, Response, NextFunction } from 'express'; -import { plainToInstance } from 'class-transformer'; -import { validate, ValidationError } from 'class-validator'; - -interface ClassType { - new (): T; -} - -/** - * Extract validation error messages recursively - */ -function extractErrors(errors: ValidationError[]): Record { - const result: Record = {}; - - for (const error of errors) { - if (error.constraints) { - result[error.property] = Object.values(error.constraints); - } - if (error.children && error.children.length > 0) { - const childErrors = extractErrors(error.children); - for (const [key, messages] of Object.entries(childErrors)) { - result[`${error.property}.${key}`] = messages; - } - } - } - - return result; -} - -/** - * Middleware factory for DTO validation - * - * @param DtoClass - The DTO class to validate against - * @param source - Where to get data from ('body' | 'query' | 'params') - * @returns Express middleware function - */ -export function validateDto( - DtoClass: ClassType, - source: 'body' | 'query' | 'params' = 'body', -) { - return async (req: Request, res: Response, next: NextFunction) => { - const data = req[source]; - - // Transform plain object to class instance - const dtoInstance = plainToInstance(DtoClass, data, { - enableImplicitConversion: true, - excludeExtraneousValues: false, - }); - - // Validate - const errors = await validate(dtoInstance, { - whitelist: true, - forbidNonWhitelisted: false, - skipMissingProperties: false, - }); - - if (errors.length > 0) { - const formattedErrors = extractErrors(errors); - - return res.status(400).json({ - success: false, - error: 'Validation failed', - details: formattedErrors, - }); - } - - // Replace request data with validated/transformed instance - req[source] = dtoInstance as any; - - next(); - }; -} - -/** - * Shorthand for body validation (most common case) - */ -export function validateBody(DtoClass: ClassType) { - return validateDto(DtoClass, 'body'); -} - -/** - * Shorthand for query validation - */ -export function validateQuery(DtoClass: ClassType) { - return validateDto(DtoClass, 'query'); -} - -/** - * Shorthand for params validation - */ -export function validateParams(DtoClass: ClassType) { - return validateDto(DtoClass, 'params'); -} diff --git a/apps/backend/src/shared/types/common.types.ts b/apps/backend/src/shared/types/common.types.ts deleted file mode 100644 index 0167711..0000000 --- a/apps/backend/src/shared/types/common.types.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * Common Types - * Shared type definitions for OrbiQuant IA - */ - -// Pagination -export interface PaginationParams { - page?: number; - perPage?: number; - sortBy?: string; - sortOrder?: 'asc' | 'desc'; -} - -export interface PaginatedResult { - data: T[]; - pagination: { - page: number; - perPage: number; - total: number; - totalPages: number; - hasNext: boolean; - hasPrev: boolean; - }; -} - -// API Response -export interface ApiResponse { - success: boolean; - data?: T; - message?: string; - error?: ApiError; - meta?: Record; -} - -export interface ApiError { - code: string; - message: string; - field?: string; - details?: unknown; -} - -// Request context -export interface RequestContext { - userId?: string; - sessionId?: string; - ip?: string; - userAgent?: string; - traceId?: string; -} - -// Date range filter -export interface DateRangeFilter { - startDate?: Date; - endDate?: Date; -} - -// Search filter -export interface SearchFilter { - query?: string; - fields?: string[]; -} - -// ID params -export interface IdParams { - id: string; -} - -// Timestamps -export interface Timestamps { - createdAt: Date; - updatedAt: Date; -} - -export interface SoftDelete { - deletedAt?: Date | null; -} - -// Base entity -export interface BaseEntity extends Timestamps { - id: string; -} - -// Audit metadata -export interface AuditMetadata { - createdBy?: string; - updatedBy?: string; - createdAt: Date; - updatedAt: Date; -} - -// File upload -export interface FileUpload { - filename: string; - mimetype: string; - size: number; - buffer: Buffer; -} - -export interface UploadedFile { - url: string; - key: string; - filename: string; - mimetype: string; - size: number; -} - -// Webhook payload -export interface WebhookPayload { - event: string; - data: T; - timestamp: string; - signature?: string; -} diff --git a/apps/backend/src/shared/types/index.ts b/apps/backend/src/shared/types/index.ts deleted file mode 100644 index 6bd1c9a..0000000 --- a/apps/backend/src/shared/types/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Shared Types - Barrel Export - */ - -export * from './common.types'; diff --git a/apps/backend/src/shared/utils/logger.ts b/apps/backend/src/shared/utils/logger.ts deleted file mode 100644 index a8ddd1a..0000000 --- a/apps/backend/src/shared/utils/logger.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Logger utility using Winston - */ - -import winston from 'winston'; - -const { combine, timestamp, printf, colorize, errors } = winston.format; - -const logFormat = printf(({ level, message, timestamp, stack }) => { - return `${timestamp} [${level}]: ${stack || message}`; -}); - -export const logger = winston.createLogger({ - level: process.env.LOG_LEVEL || 'info', - format: combine( - timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), - errors({ stack: true }), - logFormat - ), - transports: [ - new winston.transports.Console({ - format: combine( - colorize(), - timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), - errors({ stack: true }), - logFormat - ), - }), - // Add file transport in production - ...(process.env.NODE_ENV === 'production' - ? [ - new winston.transports.File({ filename: 'logs/error.log', level: 'error' }), - new winston.transports.File({ filename: 'logs/combined.log' }), - ] - : []), - ], -}); diff --git a/apps/backend/test-websocket.html b/apps/backend/test-websocket.html deleted file mode 100644 index 15f6193..0000000 --- a/apps/backend/test-websocket.html +++ /dev/null @@ -1,506 +0,0 @@ - - - - - - OrbiQuant WebSocket Test - - - -
-

OrbiQuant WebSocket Test Dashboard

- -
-
Disconnected
-
-
- - - - -
-
-
-
0
-
Messages Received
-
-
-
0
-
Active Subscriptions
-
-
-
0s
-
Connection Time
-
-
-
- -
-

Subscribe to Channels

- -
- - - -
- -
- - - -
- -
- - - - -
- -
- - - -
-
- -
-

WebSocket Messages

-
-
-
- - - - diff --git a/apps/backend/test-websocket.js b/apps/backend/test-websocket.js deleted file mode 100644 index 247c420..0000000 --- a/apps/backend/test-websocket.js +++ /dev/null @@ -1,137 +0,0 @@ -#!/usr/bin/env node - -/** - * WebSocket Test Client - * Simple script to test WebSocket connection and subscriptions - * - * Usage: - * node test-websocket.js - */ - -const WebSocket = require('ws'); - -const WS_URL = 'ws://localhost:3000/ws'; - -console.log('OrbiQuant WebSocket Test Client'); -console.log('================================\n'); - -const ws = new WebSocket(WS_URL); - -let messageCount = 0; -const startTime = Date.now(); - -ws.on('open', () => { - console.log('✅ Connected to WebSocket server'); - console.log(` URL: ${WS_URL}\n`); - - // Subscribe to multiple channels - console.log('📡 Subscribing to channels...'); - ws.send(JSON.stringify({ - type: 'subscribe', - channels: [ - 'price:BTCUSDT', - 'ticker:ETHUSDT', - 'klines:BTCUSDT:1m' - ] - })); -}); - -ws.on('message', (data) => { - messageCount++; - const msg = JSON.parse(data.toString()); - - const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); - - switch (msg.type) { - case 'connected': - console.log(`\n🔌 Server welcome message:`); - console.log(` Client ID: ${msg.data.clientId}`); - console.log(` Authenticated: ${msg.data.authenticated}`); - console.log(` Timestamp: ${msg.data.timestamp}\n`); - break; - - case 'subscribed': - console.log(`✅ Subscribed to: ${msg.channel}`); - break; - - case 'price': - console.log(`[${elapsed}s] 💰 PRICE UPDATE - ${msg.data.symbol}`); - console.log(` Price: $${msg.data.price.toLocaleString()}`); - console.log(` 24h Change: ${msg.data.changePercent24h >= 0 ? '+' : ''}${msg.data.changePercent24h.toFixed(2)}%`); - console.log(` Volume: ${msg.data.volume24h.toLocaleString()}\n`); - break; - - case 'ticker': - console.log(`[${elapsed}s] 📊 TICKER UPDATE - ${msg.data.symbol}`); - console.log(` Price: $${msg.data.price.toLocaleString()}`); - console.log(` Bid/Ask: $${msg.data.bid} / $${msg.data.ask}`); - console.log(` 24h: ${msg.data.changePercent >= 0 ? '+' : ''}${msg.data.changePercent.toFixed(2)}%`); - console.log(` High/Low: $${msg.data.high} / $${msg.data.low}\n`); - break; - - case 'kline': - const kline = msg.data; - console.log(`[${elapsed}s] 📈 KLINE UPDATE - ${kline.symbol} (${kline.interval})`); - console.log(` O: $${kline.open} H: $${kline.high} L: $${kline.low} C: $${kline.close}`); - console.log(` Volume: ${kline.volume.toFixed(4)}`); - console.log(` Status: ${kline.isFinal ? '✓ Closed' : '⏳ Updating'}\n`); - break; - - case 'trade': - console.log(`[${elapsed}s] 💸 TRADE - ${msg.data.symbol}`); - console.log(` ${msg.data.side.toUpperCase()}: ${msg.data.quantity} @ $${msg.data.price}\n`); - break; - - case 'pong': - console.log(`[${elapsed}s] 🏓 Pong received (heartbeat OK)\n`); - break; - - case 'error': - console.error(`[${elapsed}s] ❌ ERROR:`); - console.error(` ${msg.data.message}\n`); - break; - - default: - console.log(`[${elapsed}s] 📨 ${msg.type}:`, JSON.stringify(msg.data || {}, null, 2), '\n'); - } -}); - -ws.on('error', (error) => { - console.error('\n❌ WebSocket Error:', error.message); - console.error('\nTroubleshooting:'); - console.error('1. Make sure backend server is running: npm run dev'); - console.error('2. Check if port 3000 is accessible'); - console.error('3. Verify WebSocket endpoint: ws://localhost:3000/ws\n'); -}); - -ws.on('close', () => { - console.log(`\n👋 Disconnected from server`); - console.log(` Total messages received: ${messageCount}`); - console.log(` Duration: ${((Date.now() - startTime) / 1000).toFixed(1)}s\n`); - process.exit(0); -}); - -// Send ping every 30 seconds -const pingInterval = setInterval(() => { - if (ws.readyState === WebSocket.OPEN) { - console.log('🏓 Sending ping...'); - ws.send(JSON.stringify({ type: 'ping' })); - } -}, 30000); - -// Auto-disconnect after 60 seconds (for testing) -setTimeout(() => { - console.log('\n⏰ Test duration reached (60s), disconnecting...'); - clearInterval(pingInterval); - ws.close(); -}, 60000); - -// Handle Ctrl+C gracefully -process.on('SIGINT', () => { - console.log('\n\n⚠️ Interrupted by user'); - clearInterval(pingInterval); - ws.close(); -}); - -console.log('⏳ Connecting to WebSocket server...'); -console.log(' (Test will run for 60 seconds, or press Ctrl+C to stop)\n'); diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json deleted file mode 100644 index dff93be..0000000 --- a/apps/backend/tsconfig.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "lib": ["ES2022"], - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true, - "isolatedModules": true, - "baseUrl": "./src", - "paths": { - "@/*": ["./*"], - "@modules/*": ["./modules/*"], - "@core/*": ["./core/*"], - "@shared/*": ["./shared/*"], - "@config/*": ["./config/*"] - } - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "**/*.test.ts"] -} diff --git a/apps/data-service/.env.example b/apps/data-service/.env.example deleted file mode 100644 index 8dae7cb..0000000 --- a/apps/data-service/.env.example +++ /dev/null @@ -1,46 +0,0 @@ -# Data Service Configuration - OrbiQuant Trading Platform -# Copy to .env and fill in your values - -# ============================================ -# Database Configuration -# ============================================ -DB_HOST=localhost -DB_PORT=5432 -DB_NAME=orbiquant_trading -DB_USER=orbiquant_user -DB_PASSWORD=orbiquant_dev_2025 - -# ============================================ -# Polygon.io / Massive.com API -# Get your API key at: https://polygon.io or https://massive.com -# ============================================ -POLYGON_API_KEY=your_api_key_here -POLYGON_BASE_URL=https://api.polygon.io -POLYGON_RATE_LIMIT=5 -POLYGON_TIER=basic # basic, starter, advanced - -# ============================================ -# MetaAPI.cloud (for MT4/MT5 access) -# Get your token at: https://metaapi.cloud -# ============================================ -METAAPI_TOKEN=your_metaapi_token_here -METAAPI_ACCOUNT_ID=your_account_id_here - -# ============================================ -# Direct MT4 Connection (alternative to MetaAPI) -# ============================================ -MT4_SERVER=your_broker_server:443 -MT4_LOGIN=your_account_number -MT4_PASSWORD=your_password -MT4_INVESTOR_MODE=true # true for read-only, false for trading - -# ============================================ -# Sync Settings -# ============================================ -SYNC_INTERVAL_MINUTES=5 -BACKFILL_DAYS=30 - -# ============================================ -# Logging -# ============================================ -LOG_LEVEL=INFO diff --git a/apps/data-service/ARCHITECTURE.md b/apps/data-service/ARCHITECTURE.md deleted file mode 100644 index 9d7ffd1..0000000 --- a/apps/data-service/ARCHITECTURE.md +++ /dev/null @@ -1,682 +0,0 @@ -# Arquitectura del Sistema - Data Service -## Integración Massive.com/Polygon.io - ---- - -## Diagrama de Arquitectura General - -``` -┌─────────────────────────────────────────────────────────────────────┐ -│ CLIENTE / FRONTEND │ -│ (Next.js / React Trading UI) │ -└────────────┬──────────────────────────────────────────┬─────────────┘ - │ │ - │ HTTP REST API │ WebSocket - ▼ ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ DATA SERVICE (FastAPI) │ -│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │ -│ │ Market Data │ │ Sync Routes │ │ WebSocket │ │ -│ │ Routes │ │ (NEW) │ │ Handler │ │ -│ │ /api/v1/* │ │ /api/sync/* │ │ /ws/stream │ │ -│ └────────┬─────────┘ └────────┬─────────┘ └──────────────────┘ │ -│ │ │ │ -│ └──────────┬──────────┘ │ -│ ▼ │ -│ ┌──────────────────────────────────────────────────────────────┐ │ -│ │ BUSINESS LOGIC LAYER │ │ -│ │ ┌────────────────┐ ┌─────────────────────────────┐ │ │ -│ │ │ Sync Service │◄────────┤ Scheduler Manager │ │ │ -│ │ │ (NEW) │ │ (NEW) │ │ │ -│ │ └────────┬───────┘ └───────────┬─────────────────┘ │ │ -│ │ │ │ │ │ -│ │ │ ┌──────────────────────────┴─────────┐ │ │ -│ │ └──┤ APScheduler (7 Jobs) │ │ │ -│ │ │ - sync_1min (every 1 min) │ │ │ -│ │ │ - sync_5min (every 5 min) │ │ │ -│ │ │ - sync_15min (every 15 min) │ │ │ -│ │ │ - sync_1hour (every 1 hour) │ │ │ -│ │ │ - sync_4hour (every 4 hours) │ │ │ -│ │ │ - sync_daily (daily 00:05 UTC) │ │ │ -│ │ │ - cleanup (weekly Sun 02:00) │ │ │ -│ │ └────────────────────────────────────┘ │ │ -│ └──────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌──────────────────────────────────────────────────────────────┐ │ -│ │ DATA PROVIDER LAYER │ │ -│ │ ┌────────────────┐ ┌──────────────┐ ┌─────────────────┐ │ │ -│ │ │ Polygon Client │ │Binance Client│ │ MT4 Client │ │ │ -│ │ │ (UPDATED) │ │ │ │ (Optional) │ │ │ -│ │ └────────┬───────┘ └──────┬───────┘ └────────┬────────┘ │ │ -│ └───────────┼──────────────────┼───────────────────┼──────────┘ │ -│ │ │ │ │ -└──────────────┼──────────────────┼───────────────────┼──────────────┘ - │ │ │ - ┌─────────▼──────┐ ┌────────▼────────┐ ┌──────▼──────┐ - │ Massive.com │ │ Binance API │ │ MetaAPI │ - │ / Polygon.io │ │ │ │ / MT4 │ - │ API │ │ │ │ │ - └────────────────┘ └─────────────────┘ └─────────────┘ - - ┌────────────────────────────┐ - │ PostgreSQL Database │ - │ ┌──────────────────────┐ │ - │ │ market_data schema │ │ - │ │ - tickers │ │ - │ │ - ohlcv_1min │ │ - │ │ - ohlcv_5min │ │ - │ │ - ohlcv_15min │ │ - │ │ - ohlcv_1hour │ │ - │ │ - ohlcv_4hour │ │ - │ │ - ohlcv_daily │ │ - │ │ - sync_status (NEW) │ │ - │ │ - trades │ │ - │ └──────────────────────┘ │ - └────────────────────────────┘ -``` - ---- - -## Flujo de Sincronización de Datos - -``` -┌────────────────────────────────────────────────────────────────────┐ -│ AUTOMATIC SYNC FLOW │ -└────────────────────────────────────────────────────────────────────┘ - -[1] Scheduler Trigger - │ - ├─→ Every 1 min → sync_1min_data() - ├─→ Every 5 min → sync_5min_data() - ├─→ Every 15 min → sync_15min_data() - ├─→ Every 1 hour → sync_1hour_data() - ├─→ Every 4 hours→ sync_4hour_data() - └─→ Daily 00:05 → sync_daily_data() - │ - ▼ -[2] Sync Service - │ - ├─→ Get active tickers from DB - │ SELECT * FROM tickers WHERE is_active = true - │ - ├─→ For each ticker: - │ │ - │ ├─→ Get last sync timestamp - │ │ SELECT MAX(timestamp) FROM ohlcv_5min WHERE ticker_id = ? - │ │ - │ ├─→ Calculate date range - │ │ start_date = last_sync_timestamp + 1 - │ │ end_date = NOW() - │ │ - │ └─→ Fetch from Polygon API - │ │ - │ ▼ -[3] Polygon Client - │ - ├─→ Check rate limit (5 req/min for free tier) - │ Wait if needed - │ - ├─→ Format symbol (e.g., EURUSD → C:EURUSD) - │ - ├─→ Call API: GET /v2/aggs/ticker/{symbol}/range/{multiplier}/{timespan}/{from}/{to} - │ Headers: Authorization: Bearer {api_key} - │ - ├─→ Handle pagination (next_url) - │ - └─→ Yield OHLCVBar objects - │ - ▼ -[4] Data Processing - │ - ├─→ Collect bars in batches (10,000 rows) - │ - ├─→ Transform to database format - │ (ticker_id, timestamp, open, high, low, close, volume, vwap, trades) - │ - └─→ Insert to database - │ - ▼ -[5] Database Insert - │ - ├─→ INSERT INTO ohlcv_5min (...) VALUES (...) - │ ON CONFLICT (ticker_id, timestamp) DO UPDATE - │ SET open = EXCLUDED.open, ... - │ - └─→ Batch insert (10K rows at a time) - │ - ▼ -[6] Update Sync Status - │ - └─→ INSERT INTO sync_status (ticker_id, timeframe, last_sync_timestamp, ...) - ON CONFLICT (ticker_id, timeframe) DO UPDATE - SET last_sync_timestamp = NOW(), status = 'success', ... -``` - ---- - -## Flujo de Request Manual - -``` -┌────────────────────────────────────────────────────────────────────┐ -│ MANUAL SYNC REQUEST FLOW │ -└────────────────────────────────────────────────────────────────────┘ - -[User/Frontend] - │ - │ POST /api/sync/sync/EURUSD - │ Body: { - │ "asset_type": "forex", - │ "timeframe": "5min", - │ "backfill_days": 30 - │ } - ▼ -[Sync Routes] - │ - ├─→ Validate symbol is supported - │ (Check TICKER_MAPPINGS config) - │ - ├─→ Parse request parameters - │ - symbol: EURUSD - │ - asset_type: forex (enum) - │ - timeframe: 5min (enum) - │ - backfill_days: 30 - │ - └─→ Call sync_service.sync_ticker_data() - │ - ▼ -[Sync Service] - │ - ├─→ Get or create ticker in DB - │ (auto-fetch details from Polygon if new) - │ - ├─→ Calculate date range - │ start_date = NOW() - 30 days - │ end_date = NOW() - │ - ├─→ Call polygon_client.get_aggregates() - │ (async generator) - │ - ├─→ Process bars in batches - │ - Collect 10K rows - │ - Insert to DB - │ - Repeat - │ - └─→ Return result - │ - ▼ -[Response] - { - "status": "success", - "symbol": "EURUSD", - "timeframe": "5min", - "rows_inserted": 8640, - "start_date": "2024-11-08T00:00:00", - "end_date": "2024-12-08T00:00:00" - } -``` - ---- - -## Estructura de Directorios - -``` -data-service/ -│ -├── src/ -│ ├── api/ -│ │ ├── __init__.py -│ │ ├── dependencies.py # Dependency injection -│ │ ├── routes.py # Main market data routes -│ │ └── sync_routes.py # [NEW] Sync management routes -│ │ -│ ├── services/ -│ │ ├── __init__.py -│ │ ├── price_adjustment.py # Price adjustment logic -│ │ ├── sync_service.py # [NEW] Data sync service -│ │ └── scheduler.py # [NEW] Automatic scheduler -│ │ -│ ├── providers/ -│ │ ├── __init__.py -│ │ ├── polygon_client.py # [EXISTING] Polygon/Massive client -│ │ ├── binance_client.py # Binance API client -│ │ └── mt4_client.py # MT4 API client -│ │ -│ ├── models/ -│ │ ├── __init__.py -│ │ └── market.py # Pydantic models -│ │ -│ ├── websocket/ -│ │ ├── __init__.py -│ │ ├── manager.py # WebSocket connection manager -│ │ └── handlers.py # WebSocket message handlers -│ │ -│ ├── config.py # Configuration management -│ ├── app.py # [EXISTING] Main application -│ ├── app_updated.py # [NEW] Updated with scheduler -│ └── main.py # Entry point -│ -├── tests/ -│ ├── __init__.py # [NEW] -│ ├── conftest.py # [NEW] Pytest config -│ ├── test_sync_service.py # [NEW] Sync service tests -│ └── test_polygon_client.py # [NEW] Client tests -│ -├── migrations/ -│ ├── 001_initial_schema.sql # [EXISTING] Initial tables -│ └── 002_sync_status.sql # [NEW] Sync status table -│ -├── examples/ -│ ├── sync_example.py # [NEW] Programmatic usage -│ └── api_examples.sh # [NEW] API call examples -│ -├── .env.example # [NEW] Environment template -├── requirements.txt # [EXISTING] Dependencies -├── requirements_sync.txt # [NEW] Additional dependencies -├── README.md # [EXISTING] Main readme -├── README_SYNC.md # [NEW] Sync documentation -├── IMPLEMENTATION_SUMMARY.md # [NEW] Technical summary -├── TECH_LEADER_REPORT.md # [NEW] Manager report -└── ARCHITECTURE.md # [NEW] This file -``` - ---- - -## Modelo de Datos - -### Tabla: tickers - -```sql -CREATE TABLE market_data.tickers ( - id SERIAL PRIMARY KEY, - symbol VARCHAR(20) UNIQUE NOT NULL, -- EURUSD, BTCUSD, etc. - name VARCHAR(100), - asset_type VARCHAR(20) NOT NULL, -- forex, crypto, index - base_currency VARCHAR(10), - quote_currency VARCHAR(10), - exchange VARCHAR(50), - price_precision INTEGER, - quantity_precision INTEGER, - min_quantity DECIMAL, - max_quantity DECIMAL, - min_notional DECIMAL, - tick_size DECIMAL, - lot_size DECIMAL, - is_active BOOLEAN DEFAULT true, - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW() -); -``` - -### Tabla: ohlcv_5min (ejemplo) - -```sql -CREATE TABLE market_data.ohlcv_5min ( - id BIGSERIAL PRIMARY KEY, - ticker_id INTEGER REFERENCES tickers(id), - timestamp TIMESTAMP NOT NULL, - open DECIMAL NOT NULL, - high DECIMAL NOT NULL, - low DECIMAL NOT NULL, - close DECIMAL NOT NULL, - volume DECIMAL, - vwap DECIMAL, -- Volume-weighted average price - trades INTEGER, -- Number of trades - ts_epoch BIGINT, -- Unix timestamp - created_at TIMESTAMP DEFAULT NOW(), - - UNIQUE(ticker_id, timestamp) -); - -CREATE INDEX idx_ohlcv_5min_ticker_timestamp - ON market_data.ohlcv_5min(ticker_id, timestamp DESC); -``` - -### Tabla: sync_status [NEW] - -```sql -CREATE TABLE market_data.sync_status ( - id SERIAL PRIMARY KEY, - ticker_id INTEGER REFERENCES tickers(id), - timeframe VARCHAR(20) NOT NULL, -- 1min, 5min, 1hour, etc. - last_sync_timestamp TIMESTAMP, -- Last successful sync - last_sync_rows INTEGER DEFAULT 0, -- Rows inserted in last sync - sync_status VARCHAR(20) NOT NULL, -- pending, success, failed - error_message TEXT, -- Error if failed - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW(), - - UNIQUE(ticker_id, timeframe) -); - -CREATE INDEX idx_sync_status_ticker ON sync_status(ticker_id); -CREATE INDEX idx_sync_status_status ON sync_status(sync_status); -``` - ---- - -## Componentes Principales - -### 1. PolygonClient (providers/polygon_client.py) - -**Responsabilidades:** -- Comunicación con Massive.com/Polygon.io API -- Rate limiting (5 req/min) -- Formateo de símbolos (EURUSD → C:EURUSD) -- Paginación de resultados -- Retry en caso de rate limit - -**Métodos principales:** -```python -async def get_aggregates( - symbol: str, - asset_type: AssetType, - timeframe: Timeframe, - start_date: datetime, - end_date: datetime -) -> AsyncGenerator[OHLCVBar]: - # Fetch historical OHLCV data - ... - -async def get_ticker_details( - symbol: str, - asset_type: AssetType -) -> Dict: - # Get ticker metadata - ... -``` - -### 2. DataSyncService (services/sync_service.py) - -**Responsabilidades:** -- Orquestación de sincronización -- Gestión de tickers en DB -- Inserción por lotes -- Tracking de estado -- Manejo de errores - -**Métodos principales:** -```python -async def sync_ticker_data( - symbol: str, - asset_type: AssetType, - timeframe: Timeframe, - backfill_days: int = 30 -) -> Dict: - # Sync specific ticker - ... - -async def sync_all_active_tickers( - timeframe: Timeframe, - backfill_days: int = 1 -) -> Dict: - # Sync all active tickers - ... - -async def get_sync_status( - symbol: Optional[str] = None -) -> List[Dict]: - # Get sync status - ... -``` - -### 3. DataSyncScheduler (services/scheduler.py) - -**Responsabilidades:** -- Programación de tareas periódicas -- Ejecución automática de syncs -- Limpieza de datos antiguos -- Control de jobs - -**Jobs:** -- sync_1min: Cada 1 minuto -- sync_5min: Cada 5 minutos -- sync_15min: Cada 15 minutos -- sync_1hour: Cada hora -- sync_4hour: Cada 4 horas -- sync_daily: Diario (00:05 UTC) -- cleanup_old_data: Semanal (Domingo 02:00) - ---- - -## Flujo de Rate Limiting - -``` -┌────────────────────────────────────────────────────────────────────┐ -│ RATE LIMITING FLOW │ -└────────────────────────────────────────────────────────────────────┘ - -[Client Request] - │ - ▼ -[PolygonClient._rate_limit_wait()] - │ - ├─→ Check current minute - │ - Is it a new minute? - │ Yes → Reset counter to 0 - │ No → Check counter - │ - ├─→ Check request count - │ - count < 5? - │ Yes → Increment counter, proceed - │ No → Calculate wait time - │ - ├─→ Wait if needed - │ wait_time = 60 - (now - last_request_time) - │ asyncio.sleep(wait_time) - │ - └─→ Reset counter, proceed - │ - ▼ -[Make API Request] - │ - ├─→ Response 200 OK - │ → Return data - │ - ├─→ Response 429 Too Many Requests - │ → Wait retry_after seconds - │ → Retry request - │ - └─→ Response 4xx/5xx - → Raise error - -Example Timeline: -00:00:00 - Request 1 ✓ (count: 1/5) -00:00:10 - Request 2 ✓ (count: 2/5) -00:00:20 - Request 3 ✓ (count: 3/5) -00:00:30 - Request 4 ✓ (count: 4/5) -00:00:40 - Request 5 ✓ (count: 5/5) -00:00:50 - Request 6 ⏸ WAIT 10s → 00:01:00 ✓ (count: 1/5) -``` - ---- - -## Escalabilidad y Performance - -### Optimizaciones Actuales - -1. **Async I/O** - - Todo el stack es asíncrono - - No bloqueo en I/O operations - - Múltiples requests concurrentes - -2. **Batch Processing** - - Inserción de 10,000 rows por batch - - Reduce round-trips a DB - - Mejor throughput - -3. **Connection Pooling** - - asyncpg pool: 5-20 connections - - Reutilización de conexiones - - Menor latencia - -4. **Database Indexing** - - Índices en (ticker_id, timestamp) - - Índices en sync_status - - Queries optimizadas - -5. **ON CONFLICT DO UPDATE** - - Upsert nativo de PostgreSQL - - Evita duplicados - - Actualiza datos existentes - -### Límites Actuales - -| Métrica | Valor | Límite | -|---------|-------|--------| -| Rate limit (free) | 5 req/min | API | -| Batch size | 10,000 rows | Configurable | -| DB connections | 5-20 | Pool | -| Concurrent syncs | 1 per timeframe | Scheduler | -| Max backfill | 365 días | Configurable | - -### Propuestas de Mejora - -1. **Redis Cache** - - Cache de símbolos frecuentes - - Reduce queries a DB - - TTL configurable - -2. **Task Queue** - - Celery o RQ - - Syncs asíncronos largos - - Retry automático - -3. **Multiple Workers** - - Paralelización de syncs - - Mayor throughput - - Load balancing - -4. **Table Partitioning** - - Partition por fecha - - Mejora performance de queries - - Mantenimiento más fácil - ---- - -## Monitoreo y Observabilidad - -### Logs - -**Niveles configurados:** -- DEBUG: Detalles de cada request -- INFO: Operaciones normales -- WARNING: Rate limits, retries -- ERROR: Fallos de sync, API errors - -**Formato:** -``` -2024-12-08 20:15:30 - sync_service - INFO - Starting sync for EURUSD (forex) - 5min -2024-12-08 20:15:31 - polygon_client - DEBUG - Rate limit check: 2/5 requests -2024-12-08 20:15:32 - sync_service - INFO - Synced 288 bars for EURUSD -2024-12-08 20:15:33 - sync_service - INFO - Sync completed: success -``` - -### Métricas Propuestas - -**Prometheus metrics:** -- `sync_duration_seconds` - Duración de cada sync -- `sync_rows_inserted_total` - Total de rows insertados -- `sync_errors_total` - Total de errores -- `api_requests_total` - Requests a Polygon API -- `rate_limit_waits_total` - Veces que se esperó por rate limit - -### Health Checks - -**Endpoints:** -- `/health` - Health general del servicio -- `/api/sync/health` - Health del sync service -- `/scheduler/status` - Estado del scheduler - ---- - -## Seguridad - -### API Keys - -- Nunca en código fuente -- Solo en variables de entorno -- .env en .gitignore -- Rotación periódica recomendada - -### Database - -- Conexiones autenticadas -- Usuario con permisos limitados -- SSL recomendado en producción - -### Rate Limiting - -- Protección contra abuse -- Límites configurables -- Logging de excesos - -### Input Validation - -- Pydantic models para requests -- Validación de símbolos soportados -- Sanitización de parámetros - ---- - -## Deployment - -### Desarrollo - -```bash -# Local -python src/app.py - -# Con reload -uvicorn src.app:app --reload --port 8001 -``` - -### Producción - -```bash -# Con Gunicorn + Uvicorn workers -gunicorn src.app:app \ - -w 4 \ - -k uvicorn.workers.UvicornWorker \ - --bind 0.0.0.0:8001 \ - --log-level info - -# Con systemd -systemctl start orbiquant-data-service -``` - -### Docker - -```dockerfile -FROM python:3.11-slim - -WORKDIR /app -COPY requirements.txt . -RUN pip install -r requirements.txt - -COPY src/ ./src/ -COPY migrations/ ./migrations/ - -ENV PYTHONPATH=/app/src - -CMD ["uvicorn", "src.app:app", "--host", "0.0.0.0", "--port", "8001"] -``` - ---- - -## Conclusión - -Esta arquitectura proporciona: - -✅ Separación clara de responsabilidades -✅ Escalabilidad horizontal y vertical -✅ Mantenibilidad y extensibilidad -✅ Observabilidad completa -✅ Alta disponibilidad -✅ Performance optimizado - -**Status:** ✅ Producción Ready - ---- - -**Última actualización:** 2024-12-08 -**Versión:** 2.0.0 diff --git a/apps/data-service/Dockerfile b/apps/data-service/Dockerfile deleted file mode 100644 index 90f9783..0000000 --- a/apps/data-service/Dockerfile +++ /dev/null @@ -1,48 +0,0 @@ -# Data Service Dockerfile -# OrbiQuant IA Trading Platform -# Python 3.11 + FastAPI - -FROM python:3.11-slim - -# Environment -ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 \ - PYTHONPATH=/app/src \ - PIP_NO_CACHE_DIR=1 \ - PIP_DISABLE_PIP_VERSION_CHECK=1 - -# System dependencies -RUN apt-get update && apt-get install -y --no-install-recommends \ - curl \ - gcc \ - libpq-dev \ - && rm -rf /var/lib/apt/lists/* - -# Create app user -RUN useradd --create-home --shell /bin/bash appuser - -# Working directory -WORKDIR /app - -# Install Python dependencies -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -# Copy source code -COPY --chown=appuser:appuser src/ /app/src/ - -# Create logs directory -RUN mkdir -p /app/logs && chown appuser:appuser /app/logs - -# Switch to non-root user -USER appuser - -# Expose port -EXPOSE 8001 - -# Health check -HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD curl -f http://localhost:8001/health || exit 1 - -# Run application -CMD ["python", "-m", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8001"] diff --git a/apps/data-service/IMPLEMENTATION_SUMMARY.md b/apps/data-service/IMPLEMENTATION_SUMMARY.md deleted file mode 100644 index f703951..0000000 --- a/apps/data-service/IMPLEMENTATION_SUMMARY.md +++ /dev/null @@ -1,452 +0,0 @@ -# Data Service - Massive.com Integration -## Implementation Summary - -**Proyecto:** OrbiQuant IA Trading Platform -**Componente:** Data Service -**Fecha:** 2024-12-08 -**Implementado por:** BACKEND-AGENT (Claude Opus 4.5) - ---- - -## Resumen Ejecutivo - -Se ha implementado exitosamente la integración completa de **Massive.com/Polygon.io** en el Data Service, incluyendo: - -- Cliente actualizado compatible con ambas APIs -- Servicio de sincronización automática multi-timeframe -- Endpoints REST para gestión de sincronización -- Scheduler con tareas periódicas automáticas -- Tests unitarios básicos -- Documentación completa - -## Archivos Creados/Modificados - -### Archivos Nuevos Creados - -#### 1. Servicios Core -- `/src/services/sync_service.py` - Servicio de sincronización (484 líneas) -- `/src/services/scheduler.py` - Scheduler automático (334 líneas) - -#### 2. API Routes -- `/src/api/sync_routes.py` - Endpoints de sincronización (355 líneas) - -#### 3. Application -- `/src/app_updated.py` - App actualizado con scheduler (267 líneas) - -#### 4. Tests -- `/tests/__init__.py` - Inicialización de tests -- `/tests/conftest.py` - Configuración pytest -- `/tests/test_sync_service.py` - Tests de sync service (210 líneas) -- `/tests/test_polygon_client.py` - Tests de cliente (198 líneas) - -#### 5. Migrations -- `/migrations/002_sync_status.sql` - Tabla de estado de sync - -#### 6. Ejemplos -- `/examples/sync_example.py` - Ejemplo de uso programático -- `/examples/api_examples.sh` - Ejemplos de API calls - -#### 7. Documentación -- `/README_SYNC.md` - Documentación completa -- `/IMPLEMENTATION_SUMMARY.md` - Este archivo -- `/.env.example` - Ejemplo de configuración -- `/requirements_sync.txt` - Dependencias adicionales - -### Archivos Existentes (No Modificados) - -El cliente Polygon existente (`/src/providers/polygon_client.py`) YA incluía: -- Soporte completo de timeframes (1m, 5m, 15m, 1h, 4h, 1d) -- Rate limiting implementado -- Async/await nativo -- Manejo de errores robusto -- Clase `DataSyncService` básica - -**Nota:** No se modificó el archivo original para mantener compatibilidad. Los comentarios actualizados ya están en el código existente. - -## Funcionalidades Implementadas - -### 1. Data Sync Service - -**Archivo:** `src/services/sync_service.py` - -```python -class DataSyncService: - - get_or_create_ticker() # Crear/obtener ticker - - sync_ticker_data() # Sync específico - - sync_all_active_tickers() # Sync masivo - - get_sync_status() # Estado de sync - - get_supported_symbols() # Símbolos disponibles -``` - -**Características:** -- Sync incremental desde última actualización -- Backfill automático de datos históricos -- Inserción por lotes (10,000 rows) -- Tracking de estado en base de datos -- Manejo de errores con partial success - -### 2. Scheduler Automático - -**Archivo:** `src/services/scheduler.py` - -**Jobs Configurados:** - -| Job | Trigger | Timeframe | Descripción | -|-----|---------|-----------|-------------| -| sync_1min | Cada 1 min | 1min | Datos de 1 minuto | -| sync_5min | Cada 5 min | 5min | Datos de 5 minutos | -| sync_15min | Cada 15 min | 15min | Datos de 15 minutos | -| sync_1hour | Cada 1 hora | 1hour | Datos horarios | -| sync_4hour | Cada 4 horas | 4hour | Datos de 4 horas | -| sync_daily | Diario (00:05 UTC) | daily | Datos diarios | -| cleanup_old_data | Semanal (Dom 02:00) | - | Limpieza de datos antiguos | - -**Características:** -- APScheduler async -- No solapamiento de jobs (max_instances=1) -- Logging detallado de cada sync -- Limpieza automática de datos antiguos - -### 3. API Endpoints - -**Archivo:** `src/api/sync_routes.py` - -**Endpoints Implementados:** - -``` -GET /api/sync/symbols → Lista símbolos soportados -GET /api/sync/symbols/{symbol} → Info de símbolo específico -POST /api/sync/sync/{symbol} → Trigger sync manual -POST /api/sync/sync-all → Sync todos los símbolos -GET /api/sync/status → Estado general de sync -GET /api/sync/status/{symbol} → Estado de símbolo -GET /api/sync/health → Health check -GET /scheduler/status → Estado del scheduler -``` - -**Modelos Pydantic:** -- `SyncSymbolRequest` -- `SyncSymbolResponse` -- `SyncStatusResponse` -- `SyncAllResponse` -- `SymbolInfo` -- `SymbolsListResponse` - -### 4. Tests Unitarios - -**Archivos:** `tests/test_*.py` - -**Coverage:** - -| Módulo | Tests | Coverage | -|--------|-------|----------| -| sync_service.py | 10 tests | Core functionality | -| polygon_client.py | 12 tests | Cliente API | - -**Tests Incluidos:** -- Creación/obtención de tickers -- Sincronización de datos -- Manejo de errores -- Rate limiting -- Formato de símbolos -- Estado de sync - -## Configuración Requerida - -### 1. Variables de Entorno - -**Mínimas requeridas:** -```bash -POLYGON_API_KEY=your_key_here -DB_HOST=localhost -DB_NAME=orbiquant_trading -DB_USER=orbiquant_user -DB_PASSWORD=your_password -``` - -**Opcionales:** -```bash -POLYGON_BASE_URL=https://api.polygon.io -POLYGON_RATE_LIMIT=5 -ENABLE_SYNC_SCHEDULER=true -SYNC_INTERVAL_MINUTES=5 -BACKFILL_DAYS=30 -``` - -### 2. Dependencias - -**Instalar:** -```bash -pip install apscheduler pytest pytest-asyncio pytest-cov -``` - -O usar: -```bash -pip install -r requirements_sync.txt -``` - -### 3. Base de Datos - -**Ejecutar migration:** -```bash -psql -U orbiquant_user -d orbiquant_trading -f migrations/002_sync_status.sql -``` - -Crea tabla `market_data.sync_status` con campos: -- ticker_id, timeframe, last_sync_timestamp -- last_sync_rows, sync_status, error_message - -## Uso - -### Opción 1: API REST - -```bash -# Listar símbolos -curl http://localhost:8001/api/sync/symbols - -# Sincronizar EURUSD -curl -X POST http://localhost:8001/api/sync/sync/EURUSD \ - -H "Content-Type: application/json" \ - -d '{"asset_type":"forex","timeframe":"5min","backfill_days":30}' - -# Ver estado -curl http://localhost:8001/api/sync/status -``` - -### Opción 2: Programático - -```python -from services.sync_service import DataSyncService -from providers.polygon_client import PolygonClient, AssetType, Timeframe - -# Inicializar -client = PolygonClient(api_key="your_key") -service = DataSyncService(client, db_pool) - -# Sincronizar -result = await service.sync_ticker_data( - symbol="EURUSD", - asset_type=AssetType.FOREX, - timeframe=Timeframe.MINUTE_5, - backfill_days=30 -) -``` - -### Opción 3: Automático - -El scheduler se inicia automáticamente con la aplicación y ejecuta syncs periódicos según configuración. - -## Estadísticas del Código - -### Líneas de Código - -| Archivo | Líneas | Tipo | -|---------|--------|------| -| sync_service.py | 484 | Service | -| scheduler.py | 334 | Service | -| sync_routes.py | 355 | API | -| app_updated.py | 267 | App | -| test_sync_service.py | 210 | Tests | -| test_polygon_client.py | 198 | Tests | -| **TOTAL** | **1,848** | **Nuevo Código** | - -### Estructura de Archivos - -``` -data-service/ -├── src/ -│ ├── api/ -│ │ └── sync_routes.py [NUEVO] -│ ├── services/ -│ │ ├── sync_service.py [NUEVO] -│ │ └── scheduler.py [NUEVO] -│ ├── providers/ -│ │ └── polygon_client.py [EXISTENTE - Sin cambios] -│ ├── app.py [EXISTENTE] -│ └── app_updated.py [NUEVO - Versión mejorada] -├── tests/ -│ ├── __init__.py [NUEVO] -│ ├── conftest.py [NUEVO] -│ ├── test_sync_service.py [NUEVO] -│ └── test_polygon_client.py [NUEVO] -├── migrations/ -│ └── 002_sync_status.sql [NUEVO] -├── examples/ -│ ├── sync_example.py [NUEVO] -│ └── api_examples.sh [NUEVO] -├── .env.example [NUEVO] -├── requirements_sync.txt [NUEVO] -├── README_SYNC.md [NUEVO] -└── IMPLEMENTATION_SUMMARY.md [NUEVO - Este archivo] -``` - -## Símbolos Soportados - -### Forex (25+ pares) -- Majors: EURUSD, GBPUSD, USDJPY, USDCAD, AUDUSD, NZDUSD -- Minors: EURGBP, EURAUD, EURCHF, GBPJPY, etc. -- Crosses: GBPCAD, AUDCAD, AUDNZD, etc. - -### Crypto (1+) -- BTCUSD (ampliable) - -### Índices (4+) -- SPX500, NAS100, DJI30, DAX40 - -### Commodities -- XAUUSD (Gold), XAGUSD (Silver) - -**Total:** ~45 símbolos configurados - -## Rate Limits - -| Tier | Requests/Min | Config | -|------|--------------|--------| -| Free (Basic) | 5 | `POLYGON_RATE_LIMIT=5` | -| Starter | 100 | `POLYGON_RATE_LIMIT=100` | -| Advanced | Unlimited | `POLYGON_RATE_LIMIT=999` | - -## Manejo de Errores - -**Implementado:** -1. Rate limiting automático con wait -2. Retry en caso de 429 (Too Many Requests) -3. Logging de todos los errores -4. Tracking de errores en sync_status -5. Partial success (guarda datos hasta el error) -6. Timeout handling -7. Validación de símbolos - -## Performance - -**Métricas Estimadas:** - -| Operación | Tiempo | Notas | -|-----------|--------|-------| -| Sync 1 símbolo (30 días, 5min) | ~10-15s | 8,640 bars | -| Sync 1 símbolo (7 días, 1min) | ~15-20s | 10,080 bars | -| Sync 10 símbolos (1 día, 5min) | ~2-3 min | Con rate limit | -| Insert batch (10k rows) | ~1-2s | Postgres | - -**Optimizaciones:** -- Inserción por lotes (10,000 rows) -- Índices en sync_status -- ON CONFLICT DO UPDATE (upsert) -- Async I/O en toda la stack - -## Próximos Pasos - -### Mejoras Sugeridas - -1. **Monitoring:** - - Prometheus metrics - - Grafana dashboards - - Alertas de sync failures - -2. **Optimización:** - - Cache en Redis para símbolos frecuentes - - Compresión de datos antiguos - - Particionamiento de tablas por fecha - -3. **Features:** - - Webhooks para notificar sync completado - - Admin UI para gestión de sync - - Retry automático de syncs fallidos - - Priorización de símbolos más usados - -4. **Escalabilidad:** - - Task queue (Celery/RQ) para syncs largos - - Múltiples workers - - Distribución de carga - -## Testing - -### Ejecutar Tests - -```bash -# Todos los tests -pytest tests/ -v - -# Con coverage -pytest tests/ --cov=src --cov-report=html - -# Tests específicos -pytest tests/test_sync_service.py -v -``` - -### Test Manual - -```bash -# Ejecutar ejemplo -python examples/sync_example.py - -# Ejecutar API examples -chmod +x examples/api_examples.sh -./examples/api_examples.sh -``` - -## Compatibilidad - -### Polygon.io vs Massive.com - -**100% Compatible** - Ambas APIs son idénticas: -- Misma estructura de endpoints -- Mismos parámetros -- Misma autenticación -- Mismo formato de respuesta - -**Solo cambia el dominio:** -- `api.polygon.io` → API original -- `api.massive.com` → Rebrand - -**Configuración:** -```bash -# Opción 1: Polygon.io -POLYGON_BASE_URL=https://api.polygon.io - -# Opción 2: Massive.com -POLYGON_BASE_URL=https://api.massive.com - -# Ambos funcionan con el mismo API key -``` - -## Documentación - -### Archivos de Documentación - -1. **README_SYNC.md** - Documentación completa del usuario -2. **IMPLEMENTATION_SUMMARY.md** - Este archivo (resumen técnico) -3. **.env.example** - Configuración de ejemplo -4. **examples/sync_example.py** - Ejemplo de uso -5. **examples/api_examples.sh** - Ejemplos de API calls - -### Documentación API - -Disponible en: -- Swagger UI: `http://localhost:8001/docs` -- ReDoc: `http://localhost:8001/redoc` - -## Conclusión - -Se ha implementado exitosamente una integración robusta y completa de Massive.com/Polygon.io que incluye: - -✅ Cliente actualizado y compatible -✅ Servicio de sincronización multi-timeframe -✅ Scheduler automático con 7 jobs -✅ 6 nuevos endpoints REST -✅ Tests unitarios (22 tests) -✅ Migrations de base de datos -✅ Documentación completa -✅ Ejemplos de uso - -**Total de Código Nuevo:** ~1,850 líneas -**Archivos Creados:** 14 archivos -**Tiempo de Implementación:** ~2 horas - -La implementación está lista para producción y puede comenzar a sincronizar datos inmediatamente después de configurar el API key. - ---- - -**Implementado por:** BACKEND-AGENT (Claude Opus 4.5) -**Fecha:** 2024-12-08 -**Status:** ✅ COMPLETO diff --git a/apps/data-service/README.md b/apps/data-service/README.md deleted file mode 100644 index c6c1535..0000000 --- a/apps/data-service/README.md +++ /dev/null @@ -1,151 +0,0 @@ -# Data Service - -Market data service for the OrbiQuant IA Trading Platform. - -## Features - -- **REST API**: FastAPI-based endpoints for market data -- **WebSocket Streaming**: Real-time price updates -- **Multi-Provider Support**: Polygon.io, Binance, MT4/MT5 -- **Historical Data**: OHLCV candles with multiple timeframes -- **Spread Tracking**: Broker spread monitoring and statistics -- **Price Adjustment**: ML-based price adjustment models - -## Quick Start - -```bash -# Install dependencies -pip install -r requirements.txt - -# Set environment variables -cp .env.example .env - -# Run development server -python -m uvicorn src.app:app --reload --port 8001 - -# Or with Docker -docker-compose up -d -``` - -## API Endpoints - -### Health -- `GET /health` - Service health status -- `GET /ready` - Kubernetes readiness probe -- `GET /live` - Kubernetes liveness probe - -### Symbols -- `GET /api/v1/symbols` - List trading symbols -- `GET /api/v1/symbols/{symbol}` - Get symbol info - -### Market Data -- `GET /api/v1/ticker/{symbol}` - Current price -- `GET /api/v1/tickers` - Multiple tickers -- `GET /api/v1/candles/{symbol}` - Historical OHLCV -- `GET /api/v1/orderbook/{symbol}` - Order book snapshot -- `GET /api/v1/trades/{symbol}` - Recent trades - -### Admin -- `POST /api/v1/admin/backfill/{symbol}` - Trigger data backfill -- `POST /api/v1/admin/sync` - Trigger sync - -## WebSocket - -Connect to `/ws/stream` for real-time data. - -```javascript -const ws = new WebSocket('ws://localhost:8001/ws/stream'); - -ws.onopen = () => { - // Subscribe to ticker updates - ws.send(JSON.stringify({ - action: 'subscribe', - channel: 'ticker', - symbols: ['EURUSD', 'BTCUSD'] - })); -}; - -ws.onmessage = (event) => { - const data = JSON.parse(event.data); - console.log(data); -}; -``` - -### Channels -- `ticker` - Real-time price updates -- `candles` - OHLCV candle updates (specify timeframe) -- `orderbook` - Order book snapshots -- `trades` - Recent trades -- `signals` - ML trading signals - -## Architecture - -``` -src/ -├── app.py # FastAPI application -├── main.py # Scheduler-based service -├── config.py # Configuration -├── api/ -│ ├── routes.py # REST endpoints -│ └── dependencies.py # FastAPI dependencies -├── websocket/ -│ ├── manager.py # Connection management -│ └── handlers.py # WebSocket routes -├── models/ -│ └── market.py # Pydantic models -├── providers/ -│ ├── polygon_client.py # Polygon.io client -│ ├── binance_client.py # Binance client -│ └── mt4_client.py # MT4/MetaAPI client -└── services/ - └── price_adjustment.py # Price adjustment service -``` - -## Environment Variables - -```env -# Database -DB_HOST=localhost -DB_PORT=5432 -DB_NAME=orbiquant_trading -DB_USER=orbiquant_user -DB_PASSWORD=orbiquant_dev_2025 - -# Polygon.io -POLYGON_API_KEY=your_api_key -POLYGON_TIER=basic - -# Binance -BINANCE_API_KEY=your_api_key -BINANCE_API_SECRET=your_secret -BINANCE_TESTNET=false - -# MetaAPI (MT4/MT5) -METAAPI_TOKEN=your_token -METAAPI_ACCOUNT_ID=your_account_id - -# Service -SYNC_INTERVAL_MINUTES=5 -BACKFILL_DAYS=30 -LOG_LEVEL=INFO -``` - -## Development - -```bash -# Run tests -pytest - -# Code formatting -black src/ -isort src/ - -# Type checking -mypy src/ -``` - -## API Documentation - -When running, visit: -- Swagger UI: http://localhost:8001/docs -- ReDoc: http://localhost:8001/redoc diff --git a/apps/data-service/README_SYNC.md b/apps/data-service/README_SYNC.md deleted file mode 100644 index 6ec310c..0000000 --- a/apps/data-service/README_SYNC.md +++ /dev/null @@ -1,375 +0,0 @@ -# Data Service - Massive.com Integration - -## Resumen - -Integración completa de Massive.com (rebrand de Polygon.io) para el Data Service de OrbiQuant IA Trading Platform. - -## Características Implementadas - -### 1. Cliente Polygon/Massive Mejorado -- **Archivo**: `src/providers/polygon_client.py` -- Soporte para ambas URLs (api.polygon.io y api.massive.com) -- Rate limiting inteligente (5 req/min para tier gratuito) -- Soporte completo de timeframes: 1m, 5m, 15m, 1h, 4h, 1d -- Manejo robusto de errores y reintentos -- Async/await nativo para mejor performance - -### 2. Servicio de Sincronización -- **Archivo**: `src/services/sync_service.py` -- Sincronización automática de datos históricos -- Backfill inteligente desde última sincronización -- Inserción por lotes para mejor performance -- Tracking de estado de sincronización -- Soporte multi-timeframe - -### 3. Endpoints de Sincronización -- **Archivo**: `src/api/sync_routes.py` - -#### Endpoints Disponibles: - -``` -GET /api/sync/symbols - Lista de símbolos soportados -GET /api/sync/symbols/{symbol} - Info de símbolo específico -POST /api/sync/sync/{symbol} - Sincronizar símbolo -POST /api/sync/sync-all - Sincronizar todos los símbolos -GET /api/sync/status - Estado de sincronización -GET /api/sync/status/{symbol} - Estado de símbolo específico -GET /api/sync/health - Health check del servicio -``` - -### 4. Scheduler Automático -- **Archivo**: `src/services/scheduler.py` -- Sincronización periódica automática: - - **1min data**: Cada minuto - - **5min data**: Cada 5 minutos - - **15min data**: Cada 15 minutos - - **1h data**: Cada hora - - **4h data**: Cada 4 horas - - **Daily data**: Diariamente a medianoche UTC -- Limpieza automática de datos antiguos (semanal) - -### 5. Tests Básicos -- **Archivos**: `tests/test_*.py` -- Tests unitarios para sync_service -- Tests unitarios para polygon_client -- Coverage de funcionalidad crítica - -## Instalación - -### Dependencias Adicionales - -Agregar al `requirements.txt`: - -```txt -apscheduler>=3.10.4 -pytest>=7.4.0 -pytest-asyncio>=0.21.0 -``` - -### Variables de Entorno - -```bash -# API Keys (usar una de las dos) -POLYGON_API_KEY=your_polygon_api_key -MASSIVE_API_KEY=your_massive_api_key # Funciona igual que Polygon - -# Base URL (opcional - por defecto usa api.polygon.io) -POLYGON_BASE_URL=https://api.polygon.io -# O para usar Massive directamente: -# POLYGON_BASE_URL=https://api.massive.com - -# Rate Limiting -POLYGON_RATE_LIMIT=5 # requests por minuto (tier gratuito) -POLYGON_TIER=basic # basic, starter, advanced - -# Sync Configuration -ENABLE_SYNC_SCHEDULER=true -SYNC_INTERVAL_MINUTES=5 -BACKFILL_DAYS=30 - -# Database -DB_HOST=localhost -DB_PORT=5432 -DB_NAME=orbiquant_trading -DB_USER=orbiquant_user -DB_PASSWORD=orbiquant_dev_2025 -``` - -## Uso - -### 1. Iniciar el Servicio - -```bash -cd /home/isem/workspace/projects/trading-platform/apps/data-service - -# Instalar dependencias -pip install -r requirements.txt - -# Copiar app actualizado -cp src/app_updated.py src/app.py - -# Iniciar servicio -python src/app.py -``` - -### 2. Ver Símbolos Disponibles - -```bash -curl http://localhost:8001/api/sync/symbols -``` - -Respuesta: -```json -{ - "symbols": [ - { - "symbol": "EURUSD", - "polygon_symbol": "C:EURUSD", - "mt4_symbol": "EURUSD", - "asset_type": "forex", - "pip_value": 0.0001, - "supported": true - } - ], - "total": 45, - "asset_types": ["forex", "crypto", "index"] -} -``` - -### 3. Sincronizar un Símbolo - -```bash -curl -X POST "http://localhost:8001/api/sync/sync/EURUSD" \ - -H "Content-Type: application/json" \ - -d '{ - "asset_type": "forex", - "timeframe": "5min", - "backfill_days": 30 - }' -``` - -Respuesta: -```json -{ - "status": "success", - "symbol": "EURUSD", - "timeframe": "5min", - "rows_inserted": 8640, - "start_date": "2024-11-08T00:00:00", - "end_date": "2024-12-08T00:00:00" -} -``` - -### 4. Ver Estado de Sincronización - -```bash -curl http://localhost:8001/api/sync/status -``` - -Respuesta: -```json -[ - { - "symbol": "EURUSD", - "asset_type": "forex", - "timeframe": "5min", - "last_sync": "2024-12-08T20:00:00", - "rows_synced": 8640, - "status": "success", - "error": null, - "updated_at": "2024-12-08T20:05:00" - } -] -``` - -### 5. Ver Estado del Scheduler - -```bash -curl http://localhost:8001/scheduler/status -``` - -Respuesta: -```json -{ - "enabled": true, - "running": true, - "jobs": [ - { - "id": "sync_5min", - "name": "Sync 5-minute data", - "next_run": "2024-12-08T20:10:00", - "trigger": "interval[0:05:00]" - } - ], - "total_jobs": 7 -} -``` - -## Arquitectura - -``` -┌─────────────────────────────────────────────────────────┐ -│ FastAPI Application │ -│ (app.py) │ -└────────────┬────────────────────────────────────────────┘ - │ - ┌───────┴───────┐ - │ │ -┌────▼─────┐ ┌────▼──────┐ -│ Market │ │ Sync │ -│ Data │ │ Routes │ -│ Routes │ │ │ -└────┬─────┘ └────┬──────┘ - │ │ - │ ┌────▼──────────┐ - │ │ Sync Service │ - │ │ │ - │ └────┬──────────┘ - │ │ - │ ┌────▼──────────┐ - │ │ Scheduler │ - │ │ Manager │ - │ └────┬──────────┘ - │ │ -┌────▼──────────────▼─────┐ -│ Polygon/Massive │ -│ Client │ -│ (polygon_client.py) │ -└────┬────────────────────┘ - │ -┌────▼──────────────┐ -│ Massive.com API │ -│ (api.polygon.io) │ -└───────────────────┘ -``` - -## Timeframes Soportados - -| Timeframe | Valor Enum | Tabla DB | Sync Interval | -|-----------|-----------|----------|---------------| -| 1 minuto | `MINUTE_1` | `ohlcv_1min` | Cada 1 min | -| 5 minutos | `MINUTE_5` | `ohlcv_5min` | Cada 5 min | -| 15 minutos | `MINUTE_15` | `ohlcv_15min` | Cada 15 min | -| 1 hora | `HOUR_1` | `ohlcv_1hour` | Cada 1 hora | -| 4 horas | `HOUR_4` | `ohlcv_4hour` | Cada 4 horas | -| Diario | `DAY_1` | `ohlcv_daily` | Diario | - -## Asset Types Soportados - -| Asset Type | Prefix | Ejemplo | Cantidad | -|-----------|--------|---------|----------| -| Forex | `C:` | C:EURUSD | 25+ pares | -| Crypto | `X:` | X:BTCUSD | 1+ | -| Índices | `I:` | I:SPX | 4+ | -| Stocks | (none) | AAPL | Configurable | - -## Rate Limits - -### Tier Gratuito (Basic) -- 5 requests/minuto -- Implementado con rate limiting automático -- Retry automático en caso de 429 - -### Tier Starter -- 100 requests/minuto -- Configurar: `POLYGON_RATE_LIMIT=100` - -### Tier Advanced -- Sin límites -- Configurar: `POLYGON_RATE_LIMIT=999` - -## Manejo de Errores - -El servicio incluye manejo robusto de errores: - -1. **Rate Limiting**: Espera automática cuando se alcanza el límite -2. **Reintentos**: Retry en caso de errores temporales -3. **Logging**: Todas las operaciones se registran -4. **Estado de Sync**: Tracking de errores en base de datos -5. **Partial Success**: Guarda datos parciales si hay errores - -## Estructura de Base de Datos - -### Tabla sync_status - -```sql -CREATE TABLE IF NOT EXISTS market_data.sync_status ( - id SERIAL PRIMARY KEY, - ticker_id INTEGER REFERENCES market_data.tickers(id), - timeframe VARCHAR(20) NOT NULL, - last_sync_timestamp TIMESTAMP, - last_sync_rows INTEGER, - sync_status VARCHAR(20), - error_message TEXT, - updated_at TIMESTAMP DEFAULT NOW(), - UNIQUE(ticker_id, timeframe) -); -``` - -## Testing - -### Ejecutar Tests - -```bash -cd /home/isem/workspace/projects/trading-platform/apps/data-service - -# Instalar pytest -pip install pytest pytest-asyncio - -# Ejecutar todos los tests -pytest tests/ -v - -# Ejecutar tests específicos -pytest tests/test_sync_service.py -v -pytest tests/test_polygon_client.py -v - -# Con coverage -pytest tests/ --cov=src --cov-report=html -``` - -## Próximos Pasos - -1. **Configurar API Key**: Obtener API key de Massive.com o Polygon.io -2. **Crear Tablas**: Ejecutar migrations de base de datos -3. **Iniciar Servicio**: Levantar el Data Service -4. **Sync Inicial**: Ejecutar backfill de datos históricos -5. **Monitoreo**: Verificar logs y estado de sincronización - -## Troubleshooting - -### Problema: API Key inválida -``` -Solución: Verificar POLYGON_API_KEY en .env -``` - -### Problema: Rate limit excedido -``` -Solución: Reducir POLYGON_RATE_LIMIT o esperar 1 minuto -``` - -### Problema: Scheduler no inicia -``` -Solución: Verificar ENABLE_SYNC_SCHEDULER=true -``` - -### Problema: No hay datos -``` -Solución: Ejecutar POST /api/sync/sync/{symbol} manualmente -``` - -## Soporte - -Para más información sobre Massive.com/Polygon.io: -- Documentación: https://polygon.io/docs -- Massive.com: https://massive.com -- Dashboard: https://polygon.io/dashboard - -## Changelog - -### v2.0.0 (2024-12-08) -- Integración completa de Massive.com/Polygon.io -- Servicio de sincronización automática -- Scheduler con múltiples timeframes -- Endpoints de administración de sync -- Tests unitarios básicos -- Documentación completa diff --git a/apps/data-service/TECH_LEADER_REPORT.md b/apps/data-service/TECH_LEADER_REPORT.md deleted file mode 100644 index 329306e..0000000 --- a/apps/data-service/TECH_LEADER_REPORT.md +++ /dev/null @@ -1,603 +0,0 @@ -# INFORME TÉCNICO: Integración Massive.com/Polygon.io -## Data Service - OrbiQuant IA Trading Platform - -**De:** BACKEND-AGENT (Python/FastAPI) -**Para:** TECH-LEADER -**Fecha:** 2024-12-08 -**Estado:** ✅ IMPLEMENTACIÓN COMPLETA - ---- - -## Resumen Ejecutivo - -Se ha completado exitosamente la integración de **Massive.com/Polygon.io** en el Data Service con todas las funcionalidades solicitadas: - -✅ Cliente Polygon compatible con Massive.com -✅ Servicio de sincronización automática -✅ Endpoints REST completos -✅ Scheduler para sync periódico -✅ Soporte multi-timeframe (1m, 5m, 15m, 1h, 4h, 1d) -✅ Tests unitarios básicos -✅ Documentación completa - -**Total de código nuevo:** ~1,850 líneas -**Archivos creados:** 14 archivos -**Tests:** 22 tests unitarios - ---- - -## Archivos Entregables - -### 🔧 Servicios Core - -``` -/src/services/sync_service.py [NUEVO] - 484 líneas - └─ DataSyncService - ├─ sync_ticker_data() → Sincronizar símbolo específico - ├─ sync_all_active_tickers() → Sincronizar todos - ├─ get_sync_status() → Estado de sincronización - └─ get_supported_symbols() → Lista de símbolos - -/src/services/scheduler.py [NUEVO] - 334 líneas - └─ DataSyncScheduler - ├─ 7 jobs automáticos (1m, 5m, 15m, 1h, 4h, daily, cleanup) - └─ SchedulerManager (singleton) -``` - -### 🌐 API Endpoints - -``` -/src/api/sync_routes.py [NUEVO] - 355 líneas - ├─ GET /api/sync/symbols → Lista símbolos soportados - ├─ GET /api/sync/symbols/{symbol} → Info de símbolo - ├─ POST /api/sync/sync/{symbol} → Sincronizar símbolo - ├─ POST /api/sync/sync-all → Sincronizar todos - ├─ GET /api/sync/status → Estado general - ├─ GET /api/sync/status/{symbol} → Estado específico - └─ GET /api/sync/health → Health check -``` - -### 🚀 Aplicación Actualizada - -``` -/src/app_updated.py [NUEVO] - 267 líneas - └─ Incluye integración de: - ├─ Sync service - ├─ Scheduler automático - └─ Nuevas rutas -``` - -### 🧪 Tests - -``` -/tests/ - ├─ __init__.py [NUEVO] - ├─ conftest.py [NUEVO] - Config pytest - ├─ test_sync_service.py [NUEVO] - 210 líneas, 10 tests - └─ test_polygon_client.py [NUEVO] - 198 líneas, 12 tests -``` - -### 💾 Base de Datos - -``` -/migrations/002_sync_status.sql [NUEVO] - └─ Tabla: market_data.sync_status - ├─ ticker_id, timeframe - ├─ last_sync_timestamp, last_sync_rows - ├─ sync_status, error_message - └─ Índices para performance -``` - -### 📚 Documentación - -``` -/README_SYNC.md [NUEVO] - Documentación completa -/IMPLEMENTATION_SUMMARY.md [NUEVO] - Resumen técnico -/TECH_LEADER_REPORT.md [NUEVO] - Este informe -/.env.example [NUEVO] - Variables de entorno -/requirements_sync.txt [NUEVO] - Dependencias -``` - -### 📖 Ejemplos - -``` -/examples/ - ├─ sync_example.py [NUEVO] - Uso programático - └─ api_examples.sh [NUEVO] - Ejemplos API REST -``` - ---- - -## Funcionalidades Implementadas - -### 1. Sincronización Automática - -**Multi-Timeframe Support:** -- ✅ 1 minuto (1m) -- ✅ 5 minutos (5m) -- ✅ 15 minutos (15m) -- ✅ 1 hora (1h) -- ✅ 4 horas (4h) -- ✅ Diario (1d) - -**Características:** -- Sync incremental desde última actualización -- Backfill automático de históricos -- Inserción por lotes (10K rows/batch) -- Tracking de estado en DB -- Manejo de errores con partial success - -### 2. Scheduler Automático - -**Jobs Configurados:** - -| Job | Frecuencia | Backfill | Estado | -|-----|-----------|----------|---------| -| sync_1min | Cada 1 min | 1 día | ✅ Activo | -| sync_5min | Cada 5 min | 1 día | ✅ Activo | -| sync_15min | Cada 15 min | 2 días | ✅ Activo | -| sync_1hour | Cada 1 hora | 7 días | ✅ Activo | -| sync_4hour | Cada 4 horas | 30 días | ✅ Activo | -| sync_daily | Diario (00:05 UTC) | 90 días | ✅ Activo | -| cleanup | Semanal (Dom 02:00) | - | ✅ Activo | - -**Features:** -- No solapamiento de jobs -- Retry automático -- Logging detallado -- Control on/off por ENV var - -### 3. API Endpoints - -**Disponibles:** - -```bash -# Listar símbolos soportados -GET /api/sync/symbols?asset_type=forex - -# Info de símbolo específico -GET /api/sync/symbols/EURUSD - -# Sincronizar EURUSD (30 días, 5min) -POST /api/sync/sync/EURUSD -Body: {"asset_type":"forex","timeframe":"5min","backfill_days":30} - -# Estado de sincronización -GET /api/sync/status -GET /api/sync/status/EURUSD - -# Estado del scheduler -GET /scheduler/status - -# Health check -GET /api/sync/health -``` - -### 4. Rate Limiting - -**Implementado:** -- 5 req/min para tier gratuito (configurable) -- Wait automático al alcanzar límite -- Retry en caso de 429 (Too Many Requests) -- Logging de rate limit events - -**Configuración:** -```bash -POLYGON_RATE_LIMIT=5 # Free tier -POLYGON_RATE_LIMIT=100 # Starter tier -POLYGON_RATE_LIMIT=999 # Advanced tier -``` - -### 5. Manejo de Errores - -**Robusto:** -- Try/catch en todas las operaciones -- Logging de todos los errores -- Estado de error guardado en DB -- Partial success (guarda hasta donde funcionó) -- Error messages descriptivos - ---- - -## Símbolos Soportados - -**Total: ~45 símbolos configurados** - -### Forex (25+ pares) -``` -Majors: EURUSD, GBPUSD, USDJPY, USDCAD, AUDUSD, NZDUSD -Minors: EURGBP, EURAUD, EURCHF, GBPJPY, EURJPY, AUDJPY -Crosses: GBPCAD, GBPNZD, AUDCAD, AUDCHF, AUDNZD, etc. -``` - -### Crypto (1+) -``` -BTCUSD (expandible a más) -``` - -### Índices (4+) -``` -SPX500 (S&P 500), NAS100 (Nasdaq), DJI30 (Dow Jones), DAX40 -``` - -### Commodities (2+) -``` -XAUUSD (Gold), XAGUSD (Silver) -``` - ---- - -## Configuración Requerida - -### Mínima - -```bash -# .env -POLYGON_API_KEY=your_polygon_api_key_here -DB_HOST=localhost -DB_NAME=orbiquant_trading -DB_USER=orbiquant_user -DB_PASSWORD=your_password -``` - -### Completa - -Ver archivo `.env.example` para configuración completa. - -### Dependencias Adicionales - -```bash -pip install apscheduler pytest pytest-asyncio pytest-cov -``` - -O: -```bash -pip install -r requirements_sync.txt -``` - -### Base de Datos - -```bash -psql -U orbiquant_user -d orbiquant_trading \ - -f migrations/002_sync_status.sql -``` - ---- - -## Instalación y Uso - -### 1. Setup Inicial - -```bash -cd /home/isem/workspace/projects/trading-platform/apps/data-service - -# Instalar dependencias -pip install -r requirements_sync.txt - -# Configurar .env -cp .env.example .env -# Editar .env con tu API key - -# Ejecutar migration -psql -U orbiquant_user -d orbiquant_trading \ - -f migrations/002_sync_status.sql - -# Actualizar app -cp src/app_updated.py src/app.py -``` - -### 2. Iniciar Servicio - -```bash -# Opción 1: Desarrollo -python src/app.py - -# Opción 2: Producción -uvicorn src.app:app --host 0.0.0.0 --port 8001 -``` - -### 3. Verificar Instalación - -```bash -# Health check -curl http://localhost:8001/health - -# Lista de símbolos -curl http://localhost:8001/api/sync/symbols - -# Estado del scheduler -curl http://localhost:8001/scheduler/status -``` - -### 4. Primer Sync - -```bash -# Sincronizar EURUSD (últimos 30 días, 5min) -curl -X POST http://localhost:8001/api/sync/sync/EURUSD \ - -H "Content-Type: application/json" \ - -d '{ - "asset_type": "forex", - "timeframe": "5min", - "backfill_days": 30 - }' - -# Ver estado -curl http://localhost:8001/api/sync/status/EURUSD -``` - ---- - -## Performance Metrics - -### Velocidad de Sync - -| Operación | Tiempo | Datos | -|-----------|--------|-------| -| EURUSD (30d, 5min) | ~10-15s | 8,640 bars | -| EURUSD (7d, 1min) | ~15-20s | 10,080 bars | -| 10 símbolos (1d, 5min) | ~2-3 min | ~2,880 bars | - -**Factores:** -- Rate limit: 5 req/min (free tier) -- Network latency -- Database insert speed - -### Optimizaciones Implementadas - -✅ Inserción por lotes (10,000 rows) -✅ Async I/O en toda la stack -✅ ON CONFLICT DO UPDATE (upsert) -✅ Índices en sync_status -✅ Connection pooling (5-20 connections) - ---- - -## Testing - -### Ejecutar Tests - -```bash -# Todos los tests -pytest tests/ -v - -# Con coverage -pytest tests/ --cov=src --cov-report=html - -# Tests específicos -pytest tests/test_sync_service.py::TestDataSyncService::test_sync_ticker_data_success -v -``` - -### Coverage Actual - -``` -sync_service.py - 10 tests - Core functionality -polygon_client.py - 12 tests - API client -Total: - 22 tests -``` - -### Ejemplo Programático - -```bash -python examples/sync_example.py -``` - -### Ejemplos API - -```bash -chmod +x examples/api_examples.sh -./examples/api_examples.sh -``` - ---- - -## Compatibilidad Polygon.io vs Massive.com - -**100% Compatible** - Misma API, solo cambia el dominio: - -| Feature | Polygon.io | Massive.com | -|---------|-----------|-------------| -| Base URL | api.polygon.io | api.massive.com | -| API Key | ✅ Mismo | ✅ Mismo | -| Endpoints | ✅ Idénticos | ✅ Idénticos | -| Rate Limits | ✅ Iguales | ✅ Iguales | -| Respuestas | ✅ Mismo formato | ✅ Mismo formato | - -**Configuración:** - -```bash -# Opción 1: Polygon.io (por defecto) -POLYGON_BASE_URL=https://api.polygon.io - -# Opción 2: Massive.com -POLYGON_BASE_URL=https://api.massive.com -``` - ---- - -## Próximos Pasos Sugeridos - -### Corto Plazo (Próxima semana) - -1. **Deploy a ambiente de desarrollo** - - Configurar API key - - Ejecutar migrations - - Iniciar servicio - - Hacer sync inicial de símbolos principales - -2. **Validación** - - Verificar datos en DB - - Revisar logs del scheduler - - Probar endpoints desde frontend - -3. **Monitoreo Básico** - - Revisar logs diariamente - - Verificar sync_status en DB - - Alertas de errores - -### Mediano Plazo (Próximo mes) - -1. **Optimización** - - Agregar Redis cache - - Implementar Prometheus metrics - - Dashboard de Grafana - -2. **Escalabilidad** - - Task queue (Celery) para syncs largos - - Múltiples workers - - Load balancing - -3. **Features Adicionales** - - Webhooks para notificaciones - - Admin UI para gestión - - Retry automático inteligente - -### Largo Plazo (Próximos 3 meses) - -1. **Producción** - - Deploy a producción - - CI/CD pipeline - - Automated testing - -2. **Expansión** - - Más providers (Alpha Vantage, IEX Cloud) - - Más asset types - - Real-time websockets - ---- - -## Troubleshooting - -### Problema: API Key Inválida - -``` -Error: POLYGON_API_KEY is required -Solución: Verificar .env tiene POLYGON_API_KEY correctamente configurada -``` - -### Problema: Rate Limit Excedido - -``` -Error: Rate limited, waiting 60s -Solución: Normal en tier gratuito. Esperar o upgradearse a tier superior. -``` - -### Problema: Scheduler No Inicia - -``` -Error: Scheduler not initialized -Solución: Verificar ENABLE_SYNC_SCHEDULER=true en .env -``` - -### Problema: No Hay Datos Después de Sync - -``` -Error: No candle data for symbol -Solución: -1. Verificar sync_status en DB -2. Revisar logs para errores -3. Ejecutar sync manual: POST /api/sync/sync/{symbol} -``` - -### Problema: Tests Fallan - -``` -Error: POLYGON_API_KEY is required -Solución: Tests usan mocks, no necesitan API key real. - Verificar conftest.py está configurado. -``` - ---- - -## Documentación Adicional - -### Para Desarrolladores - -- **README_SYNC.md** - Documentación completa de usuario -- **IMPLEMENTATION_SUMMARY.md** - Detalles técnicos de implementación -- **examples/sync_example.py** - Código de ejemplo -- **examples/api_examples.sh** - Ejemplos de API calls - -### API Docs - -- **Swagger UI:** http://localhost:8001/docs -- **ReDoc:** http://localhost:8001/redoc - -### Polygon.io Docs - -- **Documentación oficial:** https://polygon.io/docs -- **Dashboard:** https://polygon.io/dashboard -- **Pricing:** https://polygon.io/pricing - ---- - -## Estadísticas de Implementación - -### Código Escrito - -| Tipo | Líneas | Porcentaje | -|------|--------|------------| -| Services | 818 | 44% | -| API Routes | 355 | 19% | -| Tests | 408 | 22% | -| App | 267 | 14% | -| **Total** | **1,848** | **100%** | - -### Archivos Creados - -| Tipo | Cantidad | -|------|----------| -| Python (.py) | 7 | -| Tests (.py) | 2 | -| SQL (.sql) | 1 | -| Markdown (.md) | 3 | -| Config (.txt, .example) | 2 | -| Scripts (.sh) | 1 | -| **Total** | **16** | - -### Tiempo de Desarrollo - -- **Análisis y diseño:** 30 min -- **Implementación core:** 60 min -- **Tests:** 20 min -- **Documentación:** 30 min -- **Total:** ~2.5 horas - ---- - -## Conclusión - -✅ **Implementación completa y funcional** de integración Massive.com/Polygon.io - -**Características destacadas:** -- Código limpio y bien documentado -- Arquitectura escalable y mantenible -- Tests con buena cobertura -- Documentación exhaustiva -- Listo para producción - -**El servicio está listo para:** -1. Iniciar sincronización automática de datos -2. Proveer datos históricos al ML engine -3. Alimentar frontend con datos en tiempo real -4. Escalar según necesidades del proyecto - -**Próximo paso:** Configurar API key y ejecutar primer sync. - ---- - -**Implementado por:** BACKEND-AGENT (Python/FastAPI) -**Revisado por:** [Pendiente revisión Tech-Leader] -**Estado:** ✅ COMPLETO Y LISTO PARA DEPLOYMENT -**Fecha:** 2024-12-08 - ---- - -## Contacto - -Para dudas o soporte sobre esta implementación, revisar: -1. README_SYNC.md para instrucciones de uso -2. IMPLEMENTATION_SUMMARY.md para detalles técnicos -3. examples/ para código de ejemplo -4. tests/ para ver cómo usar cada componente - -**¡Implementación exitosa! 🚀** diff --git a/apps/data-service/docker-compose.yml b/apps/data-service/docker-compose.yml deleted file mode 100644 index 42a4b6d..0000000 --- a/apps/data-service/docker-compose.yml +++ /dev/null @@ -1,93 +0,0 @@ -version: '3.8' - -services: - data-service: - build: - context: . - dockerfile: Dockerfile - container_name: orbiquant-data-service - restart: unless-stopped - ports: - - "${DATA_SERVICE_PORT:-8001}:8001" - environment: - # Database - - DB_HOST=${DB_HOST:-postgres} - - DB_PORT=${DB_PORT:-5432} - - DB_NAME=${DB_NAME:-orbiquant_trading} - - DB_USER=${DB_USER:-orbiquant_user} - - DB_PASSWORD=${DB_PASSWORD:-orbiquant_dev_2025} - - # Data Providers - - POLYGON_API_KEY=${POLYGON_API_KEY:-} - - POLYGON_TIER=${POLYGON_TIER:-basic} - - BINANCE_API_KEY=${BINANCE_API_KEY:-} - - BINANCE_API_SECRET=${BINANCE_API_SECRET:-} - - BINANCE_TESTNET=${BINANCE_TESTNET:-false} - - # MetaAPI (MT4/MT5) - - METAAPI_TOKEN=${METAAPI_TOKEN:-} - - METAAPI_ACCOUNT_ID=${METAAPI_ACCOUNT_ID:-} - - # Service Settings - - SYNC_INTERVAL_MINUTES=${SYNC_INTERVAL_MINUTES:-5} - - BACKFILL_DAYS=${BACKFILL_DAYS:-30} - - LOG_LEVEL=${LOG_LEVEL:-INFO} - - volumes: - - ./src:/app/src:ro - - ./logs:/app/logs - networks: - - orbiquant-network - depends_on: - postgres: - condition: service_healthy - redis: - condition: service_started - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8001/health"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 10s - - postgres: - image: timescale/timescaledb:latest-pg15 - container_name: orbiquant-timescaledb - restart: unless-stopped - ports: - - "${POSTGRES_PORT:-5432}:5432" - environment: - - POSTGRES_USER=${DB_USER:-orbiquant_user} - - POSTGRES_PASSWORD=${DB_PASSWORD:-orbiquant_dev_2025} - - POSTGRES_DB=${DB_NAME:-orbiquant_trading} - volumes: - - postgres_data:/var/lib/postgresql/data - - ./database/init:/docker-entrypoint-initdb.d:ro - networks: - - orbiquant-network - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-orbiquant_user} -d ${DB_NAME:-orbiquant_trading}"] - interval: 10s - timeout: 5s - retries: 5 - - redis: - image: redis:7-alpine - container_name: orbiquant-redis - restart: unless-stopped - ports: - - "${REDIS_PORT:-6379}:6379" - command: redis-server --appendonly yes - volumes: - - redis_data:/data - networks: - - orbiquant-network - -networks: - orbiquant-network: - driver: bridge - name: orbiquant-network - -volumes: - postgres_data: - redis_data: diff --git a/apps/data-service/environment.yml b/apps/data-service/environment.yml deleted file mode 100644 index 4a762e0..0000000 --- a/apps/data-service/environment.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: orbiquant-data-service -channels: - - conda-forge - - defaults -dependencies: - - python=3.11 - - pip>=23.0 - - # Core async and networking - - aiohttp>=3.9.0 - - asyncpg>=0.29.0 - - websockets>=12.0 - - # Data processing - - pandas>=2.1.0 - - numpy>=1.26.0 - - # Development and code quality - - pytest>=7.4.0 - - pytest-asyncio>=0.21.0 - - pytest-cov>=4.1.0 - - black>=23.0.0 - - isort>=5.12.0 - - flake8>=6.1.0 - - mypy>=1.5.0 - - # Additional dependencies via pip - - pip: - - python-dotenv>=1.0.0 - - structlog>=23.2.0 - - apscheduler>=3.10.0 - - cryptography>=41.0.0 - - pydantic>=2.0.0 - - pydantic-settings>=2.0.0 - # - metaapi-cloud-sdk>=23.0.0 # Optional, uncomment if using MetaAPI diff --git a/apps/data-service/examples/api_examples.sh b/apps/data-service/examples/api_examples.sh deleted file mode 100755 index 652d057..0000000 --- a/apps/data-service/examples/api_examples.sh +++ /dev/null @@ -1,98 +0,0 @@ -#!/bin/bash -# API Examples for Data Service - Massive.com Integration -# OrbiQuant IA Trading Platform - -BASE_URL="http://localhost:8001" - -echo "==========================================" -echo "OrbiQuant Data Service - API Examples" -echo "==========================================" -echo "" - -# 1. Check service health -echo "1. Checking service health..." -curl -s "${BASE_URL}/health" | jq '.' -echo "" - -# 2. Get root info -echo "2. Getting service info..." -curl -s "${BASE_URL}/" | jq '.' -echo "" - -# 3. List all supported symbols -echo "3. Listing all supported symbols..." -curl -s "${BASE_URL}/api/sync/symbols" | jq '.symbols | length' -echo "" - -# 4. List forex symbols only -echo "4. Listing forex symbols..." -curl -s "${BASE_URL}/api/sync/symbols?asset_type=forex" | jq '.symbols[] | {symbol, polygon_symbol, asset_type}' -echo "" - -# 5. Get info for specific symbol -echo "5. Getting EURUSD info..." -curl -s "${BASE_URL}/api/sync/symbols/EURUSD" | jq '.' -echo "" - -# 6. Sync EURUSD - 5min data (last 30 days) -echo "6. Syncing EURUSD (5min, 30 days)..." -curl -s -X POST "${BASE_URL}/api/sync/sync/EURUSD" \ - -H "Content-Type: application/json" \ - -d '{ - "asset_type": "forex", - "timeframe": "5min", - "backfill_days": 30 - }' | jq '.' -echo "" - -# 7. Sync GBPUSD - 1hour data (last 7 days) -echo "7. Syncing GBPUSD (1hour, 7 days)..." -curl -s -X POST "${BASE_URL}/api/sync/sync/GBPUSD" \ - -H "Content-Type: application/json" \ - -d '{ - "asset_type": "forex", - "timeframe": "1hour", - "backfill_days": 7 - }' | jq '.' -echo "" - -# 8. Get sync status for all tickers -echo "8. Getting sync status..." -curl -s "${BASE_URL}/api/sync/status" | jq '.[] | {symbol, timeframe, status, rows_synced}' -echo "" - -# 9. Get sync status for EURUSD only -echo "9. Getting EURUSD sync status..." -curl -s "${BASE_URL}/api/sync/status/EURUSD" | jq '.' -echo "" - -# 10. Check sync service health -echo "10. Checking sync service health..." -curl -s "${BASE_URL}/api/sync/health" | jq '.' -echo "" - -# 11. Get scheduler status -echo "11. Getting scheduler status..." -curl -s "${BASE_URL}/scheduler/status" | jq '.' -echo "" - -# 12. Get market data for EURUSD -echo "12. Getting EURUSD ticker price..." -curl -s "${BASE_URL}/api/v1/ticker/EURUSD" | jq '.' -echo "" - -# 13. Get candlestick data -echo "13. Getting EURUSD candles (1hour, last 100)..." -curl -s "${BASE_URL}/api/v1/candles/EURUSD?timeframe=1hour&limit=100" | jq '.candles | length' -echo "" - -# 14. Get symbols from main API -echo "14. Getting symbols from main API..." -curl -s "${BASE_URL}/api/v1/symbols?asset_type=forex&limit=10" | jq '.symbols[] | {symbol, asset_type}' -echo "" - -echo "==========================================" -echo "Examples completed!" -echo "==========================================" -echo "" -echo "For more info, visit: ${BASE_URL}/docs" diff --git a/apps/data-service/examples/sync_example.py b/apps/data-service/examples/sync_example.py deleted file mode 100644 index 03e0f95..0000000 --- a/apps/data-service/examples/sync_example.py +++ /dev/null @@ -1,176 +0,0 @@ -#!/usr/bin/env python3 -""" -Example: Using the Data Sync Service -OrbiQuant IA Trading Platform - -This example demonstrates how to use the sync service programmatically. -""" - -import asyncio -import asyncpg -from datetime import datetime, timedelta - -from providers.polygon_client import PolygonClient, AssetType, Timeframe -from services.sync_service import DataSyncService - - -async def main(): - """Main example function.""" - - # 1. Initialize database connection - print("Connecting to database...") - db_pool = await asyncpg.create_pool( - host="localhost", - port=5432, - database="orbiquant_trading", - user="orbiquant_user", - password="orbiquant_dev_2025", - min_size=2, - max_size=10 - ) - print("Connected!") - - # 2. Initialize Polygon/Massive client - print("\nInitializing Polygon/Massive client...") - polygon_client = PolygonClient( - api_key="YOUR_API_KEY_HERE", # Replace with your actual API key - rate_limit_per_min=5, # Free tier limit - use_massive_url=False # Set True to use api.massive.com - ) - print(f"Client initialized with base URL: {polygon_client.base_url}") - - # 3. Create sync service - print("\nCreating sync service...") - sync_service = DataSyncService( - polygon_client=polygon_client, - db_pool=db_pool, - batch_size=10000 - ) - print("Sync service ready!") - - # 4. Get list of supported symbols - print("\n" + "="*60) - print("SUPPORTED SYMBOLS") - print("="*60) - - symbols = await sync_service.get_supported_symbols() - print(f"\nTotal symbols: {len(symbols)}") - - # Group by asset type - forex_symbols = [s for s in symbols if s["asset_type"] == "forex"] - crypto_symbols = [s for s in symbols if s["asset_type"] == "crypto"] - index_symbols = [s for s in symbols if s["asset_type"] == "index"] - - print(f" - Forex: {len(forex_symbols)}") - print(f" - Crypto: {len(crypto_symbols)}") - print(f" - Indices: {len(index_symbols)}") - - # Show first 5 forex symbols - print("\nFirst 5 forex symbols:") - for sym in forex_symbols[:5]: - print(f" {sym['symbol']:10} -> {sym['polygon_symbol']}") - - # 5. Sync a specific symbol - print("\n" + "="*60) - print("SYNCING EURUSD - 5 MINUTE DATA") - print("="*60) - - result = await sync_service.sync_ticker_data( - symbol="EURUSD", - asset_type=AssetType.FOREX, - timeframe=Timeframe.MINUTE_5, - backfill_days=7 # Last 7 days - ) - - print(f"\nSync completed!") - print(f" Status: {result['status']}") - print(f" Rows inserted: {result['rows_inserted']}") - if result.get('start_date'): - print(f" Date range: {result['start_date']} to {result['end_date']}") - if result.get('error'): - print(f" Error: {result['error']}") - - # 6. Sync multiple timeframes for same symbol - print("\n" + "="*60) - print("SYNCING MULTIPLE TIMEFRAMES FOR GBPUSD") - print("="*60) - - timeframes = [ - Timeframe.MINUTE_5, - Timeframe.MINUTE_15, - Timeframe.HOUR_1, - ] - - for tf in timeframes: - print(f"\nSyncing {tf.value}...") - result = await sync_service.sync_ticker_data( - symbol="GBPUSD", - asset_type=AssetType.FOREX, - timeframe=tf, - backfill_days=3 - ) - print(f" {result['status']}: {result['rows_inserted']} rows") - - # 7. Get sync status - print("\n" + "="*60) - print("SYNC STATUS") - print("="*60) - - status = await sync_service.get_sync_status() - - print(f"\nTotal sync records: {len(status)}") - - # Show recent syncs - print("\nRecent syncs:") - for s in status[:10]: - last_sync = s['last_sync'] or "Never" - print(f" {s['symbol']:10} {s['timeframe']:10} -> {s['status']:10} ({s['rows_synced']} rows)") - - # 8. Sync status for specific symbol - print("\n" + "="*60) - print("EURUSD SYNC STATUS (ALL TIMEFRAMES)") - print("="*60) - - eurusd_status = await sync_service.get_sync_status(symbol="EURUSD") - - if eurusd_status: - print(f"\nFound {len(eurusd_status)} timeframes:") - for s in eurusd_status: - print(f" {s['timeframe']:10} - Last sync: {s['last_sync'] or 'Never'}") - print(f" Status: {s['status']}, Rows: {s['rows_synced']}") - if s['error']: - print(f" Error: {s['error']}") - else: - print("\nNo sync status found for EURUSD") - - # 9. Example: Sync all active tickers (commented out - can take a while) - # print("\n" + "="*60) - # print("SYNCING ALL ACTIVE TICKERS") - # print("="*60) - # - # result = await sync_service.sync_all_active_tickers( - # timeframe=Timeframe.MINUTE_5, - # backfill_days=1 - # ) - # - # print(f"\nSync completed!") - # print(f" Total tickers: {result['total_tickers']}") - # print(f" Successful: {result['successful']}") - # print(f" Failed: {result['failed']}") - # print(f" Total rows: {result['total_rows_inserted']}") - - # Cleanup - print("\n" + "="*60) - print("CLEANUP") - print("="*60) - - await db_pool.close() - if polygon_client._session: - await polygon_client._session.close() - - print("\nDone!") - - -if __name__ == "__main__": - # Run the example - asyncio.run(main()) diff --git a/apps/data-service/migrations/002_sync_status.sql b/apps/data-service/migrations/002_sync_status.sql deleted file mode 100644 index fe73b00..0000000 --- a/apps/data-service/migrations/002_sync_status.sql +++ /dev/null @@ -1,54 +0,0 @@ --- Migration: Add sync_status table --- OrbiQuant IA Trading Platform - Data Service --- Date: 2024-12-08 --- Purpose: Track synchronization status for market data - --- Create sync_status table -CREATE TABLE IF NOT EXISTS market_data.sync_status ( - id SERIAL PRIMARY KEY, - ticker_id INTEGER NOT NULL REFERENCES market_data.tickers(id) ON DELETE CASCADE, - timeframe VARCHAR(20) NOT NULL, - last_sync_timestamp TIMESTAMP, - last_sync_rows INTEGER DEFAULT 0, - sync_status VARCHAR(20) NOT NULL DEFAULT 'pending', - error_message TEXT, - created_at TIMESTAMP DEFAULT NOW(), - updated_at TIMESTAMP DEFAULT NOW(), - - -- Constraints - CONSTRAINT unique_ticker_timeframe UNIQUE (ticker_id, timeframe), - CONSTRAINT valid_status CHECK (sync_status IN ('pending', 'in_progress', 'success', 'failed', 'partial')) -); - --- Indexes for performance -CREATE INDEX IF NOT EXISTS idx_sync_status_ticker_id ON market_data.sync_status(ticker_id); -CREATE INDEX IF NOT EXISTS idx_sync_status_timeframe ON market_data.sync_status(timeframe); -CREATE INDEX IF NOT EXISTS idx_sync_status_status ON market_data.sync_status(sync_status); -CREATE INDEX IF NOT EXISTS idx_sync_status_last_sync ON market_data.sync_status(last_sync_timestamp); - --- Comments -COMMENT ON TABLE market_data.sync_status IS 'Tracks synchronization status for market data from external providers'; -COMMENT ON COLUMN market_data.sync_status.ticker_id IS 'Reference to ticker being synced'; -COMMENT ON COLUMN market_data.sync_status.timeframe IS 'Timeframe being synced (1min, 5min, 1hour, etc)'; -COMMENT ON COLUMN market_data.sync_status.last_sync_timestamp IS 'Last successful sync timestamp'; -COMMENT ON COLUMN market_data.sync_status.last_sync_rows IS 'Number of rows inserted in last sync'; -COMMENT ON COLUMN market_data.sync_status.sync_status IS 'Status: pending, in_progress, success, failed, partial'; -COMMENT ON COLUMN market_data.sync_status.error_message IS 'Error message if sync failed'; - --- Create updated_at trigger -CREATE OR REPLACE FUNCTION update_sync_status_timestamp() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = NOW(); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER sync_status_updated_at - BEFORE UPDATE ON market_data.sync_status - FOR EACH ROW - EXECUTE FUNCTION update_sync_status_timestamp(); - --- Grant permissions (adjust as needed) -GRANT SELECT, INSERT, UPDATE, DELETE ON market_data.sync_status TO orbiquant_user; -GRANT USAGE, SELECT ON SEQUENCE market_data.sync_status_id_seq TO orbiquant_user; diff --git a/apps/data-service/requirements.txt b/apps/data-service/requirements.txt deleted file mode 100644 index db2861f..0000000 --- a/apps/data-service/requirements.txt +++ /dev/null @@ -1,75 +0,0 @@ -# Data Service Dependencies -# OrbiQuant IA Trading Platform -# Python 3.11+ - -# ============================================================================= -# Web Framework -# ============================================================================= -fastapi>=0.109.0 -uvicorn[standard]>=0.25.0 - -# ============================================================================= -# Async HTTP & WebSocket -# ============================================================================= -aiohttp>=3.9.0 -websockets>=12.0 - -# ============================================================================= -# Database -# ============================================================================= -asyncpg>=0.29.0 - -# ============================================================================= -# Data Processing -# ============================================================================= -pandas>=2.1.0 -numpy>=1.26.0 - -# ============================================================================= -# Data Validation -# ============================================================================= -pydantic>=2.0.0 -pydantic-settings>=2.0.0 - -# ============================================================================= -# Configuration -# ============================================================================= -python-dotenv>=1.0.0 - -# ============================================================================= -# Logging -# ============================================================================= -structlog>=23.2.0 - -# ============================================================================= -# Scheduling -# ============================================================================= -apscheduler>=3.10.0 - -# ============================================================================= -# Security -# ============================================================================= -cryptography>=41.0.0 - -# ============================================================================= -# Optional: Exchange SDKs -# ============================================================================= -# metaapi-cloud-sdk>=23.0.0 # For MT4/MT5 cloud access -# python-binance>=1.0.0 # Alternative Binance SDK - -# ============================================================================= -# Testing -# ============================================================================= -pytest>=7.4.0 -pytest-asyncio>=0.21.0 -pytest-cov>=4.1.0 -httpx>=0.26.0 # For FastAPI testing - -# ============================================================================= -# Code Quality -# ============================================================================= -black>=23.0.0 -isort>=5.12.0 -flake8>=6.1.0 -mypy>=1.5.0 -ruff>=0.1.0 diff --git a/apps/data-service/requirements_sync.txt b/apps/data-service/requirements_sync.txt deleted file mode 100644 index a269e34..0000000 --- a/apps/data-service/requirements_sync.txt +++ /dev/null @@ -1,25 +0,0 @@ -# Additional requirements for Massive.com/Polygon.io integration -# OrbiQuant IA Trading Platform - Data Service - -# Core dependencies (already in main requirements.txt) -fastapi>=0.104.0 -uvicorn[standard]>=0.24.0 -asyncpg>=0.29.0 -aiohttp>=3.9.0 -pydantic>=2.5.0 -python-dotenv>=1.0.0 - -# NEW: Scheduler for automatic sync -apscheduler>=3.10.4 - -# NEW: Testing -pytest>=7.4.0 -pytest-asyncio>=0.21.0 -pytest-cov>=4.1.0 -pytest-mock>=3.12.0 - -# Optional: Better async testing -httpx>=0.25.0 - -# Optional: Monitoring -prometheus-client>=0.19.0 diff --git a/apps/data-service/src/__init__.py b/apps/data-service/src/__init__.py deleted file mode 100644 index ee5c9d7..0000000 --- a/apps/data-service/src/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -""" -Data Service - OrbiQuant IA Trading Platform - -Provides market data integration from multiple sources: -- Polygon.io / Massive.com API for historical and real-time data -- MetaTrader 4 for broker prices and trade execution -- Price adjustment model for broker vs data source alignment -- Spread tracking and analysis -""" - -__version__ = "0.1.0" diff --git a/apps/data-service/src/api/__init__.py b/apps/data-service/src/api/__init__.py deleted file mode 100644 index 529e9ee..0000000 --- a/apps/data-service/src/api/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -""" -Data Service API Module -OrbiQuant IA Trading Platform -""" - -from .routes import router -from .dependencies import get_db_pool, get_data_service - -__all__ = ["router", "get_db_pool", "get_data_service"] diff --git a/apps/data-service/src/api/dependencies.py b/apps/data-service/src/api/dependencies.py deleted file mode 100644 index 56682ff..0000000 --- a/apps/data-service/src/api/dependencies.py +++ /dev/null @@ -1,103 +0,0 @@ -""" -FastAPI Dependencies -OrbiQuant IA Trading Platform - Data Service -""" - -from typing import Optional, AsyncGenerator -import asyncpg -from fastapi import Request, HTTPException, status - -from config import Config - - -async def get_db_pool(request: Request) -> asyncpg.Pool: - """Get database connection pool from app state.""" - pool = request.app.state.db_pool - if not pool: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Database connection not available" - ) - return pool - - -async def get_db_connection(request: Request) -> AsyncGenerator[asyncpg.Connection, None]: - """Get a database connection from pool.""" - pool = await get_db_pool(request) - async with pool.acquire() as connection: - yield connection - - -def get_data_service(request: Request): - """Get DataService instance from app state.""" - service = request.app.state.data_service - if not service: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Data service not initialized" - ) - return service - - -def get_config(request: Request) -> Config: - """Get configuration from app state.""" - return request.app.state.config - - -def get_polygon_client(request: Request): - """Get Polygon client from app state.""" - client = request.app.state.polygon_client - if not client: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Polygon client not configured" - ) - return client - - -def get_mt4_client(request: Request): - """Get MT4/MetaAPI client from app state.""" - return request.app.state.mt4_client # May be None - - -class RateLimiter: - """Simple in-memory rate limiter.""" - - def __init__(self, requests_per_minute: int = 60): - self.requests_per_minute = requests_per_minute - self._requests: dict[str, list[float]] = {} - - async def check(self, client_id: str) -> bool: - """Check if client can make a request.""" - import time - now = time.time() - minute_ago = now - 60 - - if client_id not in self._requests: - self._requests[client_id] = [] - - # Clean old requests - self._requests[client_id] = [ - ts for ts in self._requests[client_id] if ts > minute_ago - ] - - if len(self._requests[client_id]) >= self.requests_per_minute: - return False - - self._requests[client_id].append(now) - return True - - -# Global rate limiter instance -rate_limiter = RateLimiter(requests_per_minute=60) - - -async def check_rate_limit(request: Request) -> None: - """Rate limit dependency.""" - client_ip = request.client.host if request.client else "unknown" - - if not await rate_limiter.check(client_ip): - raise HTTPException( - status_code=status.HTTP_429_TOO_MANY_REQUESTS, - detail="Too many requests. Please slow down." - ) diff --git a/apps/data-service/src/api/mt4_routes.py b/apps/data-service/src/api/mt4_routes.py deleted file mode 100644 index daeceeb..0000000 --- a/apps/data-service/src/api/mt4_routes.py +++ /dev/null @@ -1,555 +0,0 @@ -""" -MetaTrader 4 API Routes -OrbiQuant IA Trading Platform - -Provides REST API endpoints for MT4 account management, real-time data, -and trade execution through MetaAPI.cloud. -""" - -from fastapi import APIRouter, HTTPException, Depends, Query, Request -from pydantic import BaseModel, Field -from typing import Optional, List, Dict, Any -from datetime import datetime, timedelta -from enum import Enum -import logging - -from ..providers.metaapi_client import ( - MetaAPIClient, - OrderType, - MT4Tick, - MT4Position, - MT4Order, - MT4AccountInfo, - TradeResult, - MetaAPIError -) - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/api/mt4", tags=["MetaTrader 4"]) - - -# ========================================== -# Request/Response Models -# ========================================== - -class ConnectionRequest(BaseModel): - """Request to connect MT4 account""" - token: Optional[str] = Field(None, description="MetaAPI token (or use env)") - account_id: str = Field(..., description="MetaAPI account ID") - - -class ConnectionResponse(BaseModel): - """Connection status response""" - connected: bool - account_id: str - login: Optional[str] = None - server: Optional[str] = None - platform: Optional[str] = None - account_type: Optional[str] = None - balance: Optional[float] = None - currency: Optional[str] = None - - -class AccountInfoResponse(BaseModel): - """Full account information""" - id: str - name: str - login: str - server: str - platform: str - account_type: str - currency: str - balance: float - equity: float - margin: float - free_margin: float - margin_level: Optional[float] - leverage: int - profit: float - connected: bool - - -class TickResponse(BaseModel): - """Real-time tick data""" - symbol: str - bid: float - ask: float - spread: float - timestamp: datetime - - -class CandleResponse(BaseModel): - """OHLCV candle""" - time: datetime - open: float - high: float - low: float - close: float - volume: int - - -class PositionResponse(BaseModel): - """Open position""" - id: str - symbol: str - type: str - volume: float - open_price: float - current_price: float - stop_loss: Optional[float] - take_profit: Optional[float] - profit: float - swap: float - open_time: datetime - comment: str - - -class OpenTradeRequest(BaseModel): - """Request to open a trade""" - symbol: str = Field(..., description="Trading symbol") - action: str = Field(..., description="BUY or SELL") - volume: float = Field(..., gt=0, le=100, description="Volume in lots") - price: Optional[float] = Field(None, description="Price for pending orders") - stop_loss: Optional[float] = Field(None, description="Stop loss price") - take_profit: Optional[float] = Field(None, description="Take profit price") - comment: str = Field("OrbiQuant", description="Order comment") - - -class ModifyPositionRequest(BaseModel): - """Request to modify a position""" - stop_loss: Optional[float] = None - take_profit: Optional[float] = None - - -class TradeResponse(BaseModel): - """Trade operation response""" - success: bool - order_id: Optional[str] = None - position_id: Optional[str] = None - error: Optional[str] = None - - -# ========================================== -# Global MT4 Client State -# ========================================== - -# Store connected client (in production, use proper state management) -_mt4_client: Optional[MetaAPIClient] = None - - -def get_mt4_client() -> MetaAPIClient: - """Get the active MT4 client""" - if _mt4_client is None or not _mt4_client.is_connected: - raise HTTPException( - status_code=503, - detail="MT4 not connected. Call POST /api/mt4/connect first." - ) - return _mt4_client - - -# ========================================== -# Connection Endpoints -# ========================================== - -@router.post("/connect", response_model=ConnectionResponse) -async def connect_mt4(request: ConnectionRequest): - """ - Connect to MT4 account via MetaAPI. - - This deploys the account if needed and establishes connection to the broker. - May take 30-60 seconds on first connection. - """ - global _mt4_client - - try: - logger.info(f"Connecting to MT4 account {request.account_id}...") - - _mt4_client = MetaAPIClient( - token=request.token, - account_id=request.account_id - ) - - await _mt4_client.connect() - - info = _mt4_client.account_info - - return ConnectionResponse( - connected=True, - account_id=request.account_id, - login=info.login if info else None, - server=info.server if info else None, - platform=info.platform if info else None, - account_type=info.type if info else None, - balance=info.balance if info else None, - currency=info.currency if info else None - ) - - except MetaAPIError as e: - logger.error(f"MT4 connection failed: {e.message}") - raise HTTPException(status_code=400, detail=e.message) - except Exception as e: - logger.error(f"MT4 connection error: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/disconnect") -async def disconnect_mt4(): - """Disconnect from MT4 account""" - global _mt4_client - - if _mt4_client: - await _mt4_client.disconnect() - _mt4_client = None - - return {"status": "disconnected"} - - -@router.get("/status", response_model=ConnectionResponse) -async def get_connection_status(): - """Get current MT4 connection status""" - if _mt4_client and _mt4_client.is_connected: - info = _mt4_client.account_info - return ConnectionResponse( - connected=True, - account_id=_mt4_client.account_id, - login=info.login if info else None, - server=info.server if info else None, - platform=info.platform if info else None, - account_type=info.type if info else None, - balance=info.balance if info else None, - currency=info.currency if info else None - ) - else: - return ConnectionResponse( - connected=False, - account_id="" - ) - - -# ========================================== -# Account Information -# ========================================== - -@router.get("/account", response_model=AccountInfoResponse) -async def get_account_info(client: MetaAPIClient = Depends(get_mt4_client)): - """Get detailed account information""" - try: - info = await client.get_account_info() - - return AccountInfoResponse( - id=info.id, - name=info.name, - login=info.login, - server=info.server, - platform=info.platform, - account_type=info.type, - currency=info.currency, - balance=info.balance, - equity=info.equity, - margin=info.margin, - free_margin=info.free_margin, - margin_level=info.margin_level, - leverage=info.leverage, - profit=info.profit, - connected=info.connected - ) - - except MetaAPIError as e: - raise HTTPException(status_code=400, detail=e.message) - - -# ========================================== -# Market Data -# ========================================== - -@router.get("/tick/{symbol}", response_model=TickResponse) -async def get_tick( - symbol: str, - client: MetaAPIClient = Depends(get_mt4_client) -): - """Get current tick (bid/ask) for a symbol""" - try: - tick = await client.get_tick(symbol.upper()) - - return TickResponse( - symbol=tick.symbol, - bid=tick.bid, - ask=tick.ask, - spread=tick.spread, - timestamp=tick.timestamp - ) - - except MetaAPIError as e: - raise HTTPException(status_code=400, detail=e.message) - - -@router.get("/candles/{symbol}", response_model=List[CandleResponse]) -async def get_candles( - symbol: str, - timeframe: str = Query("1h", description="1m, 5m, 15m, 30m, 1h, 4h, 1d"), - limit: int = Query(100, ge=1, le=1000), - client: MetaAPIClient = Depends(get_mt4_client) -): - """Get historical candles for a symbol""" - try: - candles = await client.get_candles( - symbol=symbol.upper(), - timeframe=timeframe, - limit=limit - ) - - return [ - CandleResponse( - time=c.time, - open=c.open, - high=c.high, - low=c.low, - close=c.close, - volume=c.tick_volume - ) - for c in candles - ] - - except MetaAPIError as e: - raise HTTPException(status_code=400, detail=e.message) - - -@router.get("/symbols") -async def get_symbols(client: MetaAPIClient = Depends(get_mt4_client)): - """Get list of available trading symbols""" - try: - symbols = await client.get_symbols() - return {"symbols": symbols} - except MetaAPIError as e: - raise HTTPException(status_code=400, detail=e.message) - - -@router.get("/symbols/{symbol}/specification") -async def get_symbol_spec( - symbol: str, - client: MetaAPIClient = Depends(get_mt4_client) -): - """Get symbol specification (contract size, digits, etc.)""" - try: - spec = await client.get_symbol_specification(symbol.upper()) - return spec - except MetaAPIError as e: - raise HTTPException(status_code=400, detail=e.message) - - -# ========================================== -# Positions & Orders -# ========================================== - -@router.get("/positions", response_model=List[PositionResponse]) -async def get_positions(client: MetaAPIClient = Depends(get_mt4_client)): - """Get all open positions""" - try: - positions = await client.get_positions() - - return [ - PositionResponse( - id=p.id, - symbol=p.symbol, - type=p.type.value, - volume=p.volume, - open_price=p.open_price, - current_price=p.current_price, - stop_loss=p.stop_loss, - take_profit=p.take_profit, - profit=p.profit, - swap=p.swap, - open_time=p.open_time, - comment=p.comment - ) - for p in positions - ] - - except MetaAPIError as e: - raise HTTPException(status_code=400, detail=e.message) - - -@router.get("/orders") -async def get_orders(client: MetaAPIClient = Depends(get_mt4_client)): - """Get all pending orders""" - try: - orders = await client.get_orders() - return {"orders": [ - { - "id": o.id, - "symbol": o.symbol, - "type": o.type.value, - "volume": o.volume, - "price": o.open_price, - "sl": o.stop_loss, - "tp": o.take_profit, - "time": o.open_time.isoformat(), - "comment": o.comment - } - for o in orders - ]} - except MetaAPIError as e: - raise HTTPException(status_code=400, detail=e.message) - - -@router.get("/history") -async def get_history( - days: int = Query(7, ge=1, le=365), - limit: int = Query(100, ge=1, le=1000), - client: MetaAPIClient = Depends(get_mt4_client) -): - """Get trade history""" - try: - start_time = datetime.utcnow() - timedelta(days=days) - history = await client.get_history(start_time=start_time, limit=limit) - return {"history": history} - except MetaAPIError as e: - raise HTTPException(status_code=400, detail=e.message) - - -# ========================================== -# Trading Operations -# ========================================== - -@router.post("/trade", response_model=TradeResponse) -async def open_trade( - request: OpenTradeRequest, - client: MetaAPIClient = Depends(get_mt4_client) -): - """ - Open a new trade. - - For market orders, leave price as None. - For pending orders, specify the price. - """ - try: - # Map action to OrderType - action_map = { - "BUY": OrderType.BUY, - "SELL": OrderType.SELL, - "BUY_LIMIT": OrderType.BUY_LIMIT, - "SELL_LIMIT": OrderType.SELL_LIMIT, - "BUY_STOP": OrderType.BUY_STOP, - "SELL_STOP": OrderType.SELL_STOP - } - - order_type = action_map.get(request.action.upper()) - if not order_type: - raise HTTPException(status_code=400, detail=f"Invalid action: {request.action}") - - result = await client.open_trade( - symbol=request.symbol.upper(), - order_type=order_type, - volume=request.volume, - price=request.price, - sl=request.stop_loss, - tp=request.take_profit, - comment=request.comment - ) - - return TradeResponse( - success=result.success, - order_id=result.order_id, - position_id=result.position_id, - error=result.error_message - ) - - except MetaAPIError as e: - return TradeResponse(success=False, error=e.message) - - -@router.post("/positions/{position_id}/close", response_model=TradeResponse) -async def close_position( - position_id: str, - volume: Optional[float] = Query(None, description="Volume to close (None = all)"), - client: MetaAPIClient = Depends(get_mt4_client) -): - """Close an open position""" - try: - result = await client.close_position(position_id, volume) - - return TradeResponse( - success=result.success, - position_id=result.position_id, - error=result.error_message - ) - - except MetaAPIError as e: - return TradeResponse(success=False, error=e.message) - - -@router.put("/positions/{position_id}", response_model=TradeResponse) -async def modify_position( - position_id: str, - request: ModifyPositionRequest, - client: MetaAPIClient = Depends(get_mt4_client) -): - """Modify position SL/TP""" - try: - result = await client.modify_position( - position_id=position_id, - sl=request.stop_loss, - tp=request.take_profit - ) - - return TradeResponse( - success=result.success, - position_id=result.position_id, - error=result.error_message - ) - - except MetaAPIError as e: - return TradeResponse(success=False, error=e.message) - - -@router.delete("/orders/{order_id}", response_model=TradeResponse) -async def cancel_order( - order_id: str, - client: MetaAPIClient = Depends(get_mt4_client) -): - """Cancel a pending order""" - try: - result = await client.cancel_order(order_id) - - return TradeResponse( - success=result.success, - order_id=result.order_id, - error=result.error_message - ) - - except MetaAPIError as e: - return TradeResponse(success=False, error=e.message) - - -# ========================================== -# Utility Endpoints -# ========================================== - -@router.post("/calculate-margin") -async def calculate_margin( - symbol: str, - action: str, - volume: float, - price: Optional[float] = None, - client: MetaAPIClient = Depends(get_mt4_client) -): - """Calculate required margin for a trade""" - try: - action_map = {"BUY": OrderType.BUY, "SELL": OrderType.SELL} - order_type = action_map.get(action.upper()) - - if not order_type: - raise HTTPException(status_code=400, detail="Action must be BUY or SELL") - - result = await client.calculate_margin( - symbol=symbol.upper(), - order_type=order_type, - volume=volume, - price=price - ) - - return result - - except MetaAPIError as e: - raise HTTPException(status_code=400, detail=e.message) diff --git a/apps/data-service/src/api/routes.py b/apps/data-service/src/api/routes.py deleted file mode 100644 index 608e9f4..0000000 --- a/apps/data-service/src/api/routes.py +++ /dev/null @@ -1,607 +0,0 @@ -""" -FastAPI Routes -OrbiQuant IA Trading Platform - Data Service -""" - -from datetime import datetime, timedelta -from decimal import Decimal -from typing import List, Optional - -import asyncpg -from fastapi import APIRouter, Depends, HTTPException, Query, status - -from models.market import ( - Ticker, OHLCV, OrderBook, OrderBookLevel, Trade, SymbolInfo, - Timeframe, AssetType, SymbolStatus, - TickerRequest, CandlesRequest, CandlesResponse, - TickersResponse, SymbolsResponse, ServiceHealth, ProviderStatus -) -from .dependencies import ( - get_db_pool, get_db_connection, get_data_service, - get_polygon_client, check_rate_limit -) - -router = APIRouter() - - -# ============================================================================= -# Health & Status -# ============================================================================= - -@router.get("/health", response_model=ServiceHealth, tags=["Health"]) -async def health_check( - db_pool: asyncpg.Pool = Depends(get_db_pool) -): - """ - Check service health status. - - Returns overall health, provider status, and connection states. - """ - from fastapi import Request - import time - - start_time = getattr(health_check, '_start_time', time.time()) - health_check._start_time = start_time - - # Check database - db_connected = False - try: - async with db_pool.acquire() as conn: - await conn.fetchval("SELECT 1") - db_connected = True - except Exception: - pass - - # Build provider status list - providers = [ - ProviderStatus( - name="polygon", - is_connected=True, # Would check actual status - latency_ms=None, - last_update=datetime.utcnow() - ) - ] - - # Determine overall status - if db_connected: - status_str = "healthy" - else: - status_str = "unhealthy" - - return ServiceHealth( - status=status_str, - version="1.0.0", - uptime_seconds=time.time() - start_time, - providers=providers, - database_connected=db_connected, - cache_connected=True, # Would check Redis - websocket_clients=0 # Would get from WS manager - ) - - -@router.get("/ready", tags=["Health"]) -async def readiness_check(db_pool: asyncpg.Pool = Depends(get_db_pool)): - """Kubernetes readiness probe.""" - try: - async with db_pool.acquire() as conn: - await conn.fetchval("SELECT 1") - return {"status": "ready"} - except Exception as e: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail=f"Not ready: {str(e)}" - ) - - -@router.get("/live", tags=["Health"]) -async def liveness_check(): - """Kubernetes liveness probe.""" - return {"status": "alive"} - - -# ============================================================================= -# Symbols -# ============================================================================= - -@router.get("/api/v1/symbols", response_model=SymbolsResponse, tags=["Symbols"]) -async def list_symbols( - asset_type: Optional[AssetType] = None, - is_active: bool = True, - limit: int = Query(default=100, ge=1, le=500), - offset: int = Query(default=0, ge=0), - conn: asyncpg.Connection = Depends(get_db_connection) -): - """ - List available trading symbols. - - Filter by asset type and active status. - """ - query = """ - SELECT - t.id, t.symbol, t.name, t.asset_type, - t.base_currency, t.quote_currency, t.exchange, - t.price_precision, t.quantity_precision, - t.min_quantity, t.max_quantity, t.min_notional, - t.tick_size, t.lot_size, t.is_active, - t.created_at, t.updated_at - FROM market_data.tickers t - WHERE ($1::text IS NULL OR t.asset_type = $1) - AND t.is_active = $2 - ORDER BY t.symbol - LIMIT $3 OFFSET $4 - """ - - rows = await conn.fetch( - query, - asset_type.value if asset_type else None, - is_active, - limit, - offset - ) - - # Get total count - count_query = """ - SELECT COUNT(*) - FROM market_data.tickers t - WHERE ($1::text IS NULL OR t.asset_type = $1) - AND t.is_active = $2 - """ - total = await conn.fetchval( - count_query, - asset_type.value if asset_type else None, - is_active - ) - - symbols = [ - SymbolInfo( - symbol=row["symbol"], - name=row["name"] or row["symbol"], - asset_type=AssetType(row["asset_type"]), - base_currency=row["base_currency"], - quote_currency=row["quote_currency"], - exchange=row["exchange"] or "unknown", - status=SymbolStatus.TRADING if row["is_active"] else SymbolStatus.HALTED, - price_precision=row["price_precision"] or 8, - quantity_precision=row["quantity_precision"] or 8, - min_quantity=Decimal(str(row["min_quantity"])) if row["min_quantity"] else None, - max_quantity=Decimal(str(row["max_quantity"])) if row["max_quantity"] else None, - min_notional=Decimal(str(row["min_notional"])) if row["min_notional"] else None, - tick_size=Decimal(str(row["tick_size"])) if row["tick_size"] else None, - lot_size=Decimal(str(row["lot_size"])) if row["lot_size"] else None, - is_active=row["is_active"], - created_at=row["created_at"], - updated_at=row["updated_at"] - ) - for row in rows - ] - - return SymbolsResponse(symbols=symbols, total=total) - - -@router.get("/api/v1/symbols/{symbol}", response_model=SymbolInfo, tags=["Symbols"]) -async def get_symbol( - symbol: str, - conn: asyncpg.Connection = Depends(get_db_connection) -): - """Get detailed information for a specific symbol.""" - row = await conn.fetchrow( - """ - SELECT - t.id, t.symbol, t.name, t.asset_type, - t.base_currency, t.quote_currency, t.exchange, - t.price_precision, t.quantity_precision, - t.min_quantity, t.max_quantity, t.min_notional, - t.tick_size, t.lot_size, t.is_active, - t.created_at, t.updated_at - FROM market_data.tickers t - WHERE UPPER(t.symbol) = UPPER($1) - """, - symbol - ) - - if not row: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Symbol {symbol} not found" - ) - - return SymbolInfo( - symbol=row["symbol"], - name=row["name"] or row["symbol"], - asset_type=AssetType(row["asset_type"]), - base_currency=row["base_currency"], - quote_currency=row["quote_currency"], - exchange=row["exchange"] or "unknown", - status=SymbolStatus.TRADING if row["is_active"] else SymbolStatus.HALTED, - price_precision=row["price_precision"] or 8, - quantity_precision=row["quantity_precision"] or 8, - min_quantity=Decimal(str(row["min_quantity"])) if row["min_quantity"] else None, - max_quantity=Decimal(str(row["max_quantity"])) if row["max_quantity"] else None, - min_notional=Decimal(str(row["min_notional"])) if row["min_notional"] else None, - tick_size=Decimal(str(row["tick_size"])) if row["tick_size"] else None, - lot_size=Decimal(str(row["lot_size"])) if row["lot_size"] else None, - is_active=row["is_active"], - created_at=row["created_at"], - updated_at=row["updated_at"] - ) - - -# ============================================================================= -# Tickers (Real-time prices) -# ============================================================================= - -@router.get("/api/v1/ticker/{symbol}", response_model=Ticker, tags=["Market Data"]) -async def get_ticker( - symbol: str, - conn: asyncpg.Connection = Depends(get_db_connection), - _: None = Depends(check_rate_limit) -): - """ - Get current ticker price for a symbol. - - Returns latest price with 24h statistics. - """ - # Get latest price from OHLCV data - row = await conn.fetchrow( - """ - SELECT - t.symbol, - o.close as price, - o.volume, - o.timestamp, - o.high as high_24h, - o.low as low_24h, - LAG(o.close) OVER (ORDER BY o.timestamp) as prev_close - FROM market_data.tickers t - JOIN market_data.ohlcv_5min o ON o.ticker_id = t.id - WHERE UPPER(t.symbol) = UPPER($1) - ORDER BY o.timestamp DESC - LIMIT 1 - """, - symbol - ) - - if not row: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"No price data for symbol {symbol}" - ) - - price = Decimal(str(row["price"])) - prev_close = Decimal(str(row["prev_close"])) if row["prev_close"] else price - change_24h = price - prev_close - change_percent = (change_24h / prev_close * 100) if prev_close else Decimal(0) - - return Ticker( - symbol=row["symbol"], - price=price, - volume=Decimal(str(row["volume"])) if row["volume"] else None, - change_24h=change_24h, - change_percent_24h=change_percent, - high_24h=Decimal(str(row["high_24h"])) if row["high_24h"] else None, - low_24h=Decimal(str(row["low_24h"])) if row["low_24h"] else None, - timestamp=row["timestamp"] - ) - - -@router.get("/api/v1/tickers", response_model=TickersResponse, tags=["Market Data"]) -async def get_tickers( - symbols: Optional[str] = Query(None, description="Comma-separated symbols"), - asset_type: Optional[AssetType] = None, - conn: asyncpg.Connection = Depends(get_db_connection), - _: None = Depends(check_rate_limit) -): - """ - Get ticker prices for multiple symbols. - - If no symbols specified, returns all active tickers. - """ - symbol_list = symbols.split(",") if symbols else None - - query = """ - WITH latest_prices AS ( - SELECT DISTINCT ON (t.id) - t.symbol, - o.close as price, - o.volume, - o.timestamp, - o.high as high_24h, - o.low as low_24h - FROM market_data.tickers t - JOIN market_data.ohlcv_5min o ON o.ticker_id = t.id - WHERE t.is_active = true - AND ($1::text[] IS NULL OR t.symbol = ANY($1)) - AND ($2::text IS NULL OR t.asset_type = $2) - ORDER BY t.id, o.timestamp DESC - ) - SELECT * FROM latest_prices - ORDER BY symbol - """ - - rows = await conn.fetch( - query, - symbol_list, - asset_type.value if asset_type else None - ) - - tickers = [ - Ticker( - symbol=row["symbol"], - price=Decimal(str(row["price"])), - volume=Decimal(str(row["volume"])) if row["volume"] else None, - high_24h=Decimal(str(row["high_24h"])) if row["high_24h"] else None, - low_24h=Decimal(str(row["low_24h"])) if row["low_24h"] else None, - timestamp=row["timestamp"] - ) - for row in rows - ] - - return TickersResponse(tickers=tickers, timestamp=datetime.utcnow()) - - -# ============================================================================= -# Candles (OHLCV) -# ============================================================================= - -@router.get("/api/v1/candles/{symbol}", response_model=CandlesResponse, tags=["Market Data"]) -async def get_candles( - symbol: str, - timeframe: Timeframe = Timeframe.HOUR_1, - start_time: Optional[datetime] = None, - end_time: Optional[datetime] = None, - limit: int = Query(default=100, ge=1, le=1000), - conn: asyncpg.Connection = Depends(get_db_connection), - _: None = Depends(check_rate_limit) -): - """ - Get historical candlestick data for a symbol. - - Supports multiple timeframes from 1m to 1M. - """ - # Map timeframe to table - timeframe_tables = { - Timeframe.MINUTE_1: "ohlcv_1min", - Timeframe.MINUTE_5: "ohlcv_5min", - Timeframe.MINUTE_15: "ohlcv_15min", - Timeframe.MINUTE_30: "ohlcv_30min", - Timeframe.HOUR_1: "ohlcv_1hour", - Timeframe.HOUR_4: "ohlcv_4hour", - Timeframe.DAY_1: "ohlcv_daily", - Timeframe.WEEK_1: "ohlcv_weekly", - Timeframe.MONTH_1: "ohlcv_monthly", - } - - table = timeframe_tables.get(timeframe, "ohlcv_1hour") - - # Default time range - if not end_time: - end_time = datetime.utcnow() - if not start_time: - # Calculate based on timeframe - multipliers = { - Timeframe.MINUTE_1: 1, - Timeframe.MINUTE_5: 5, - Timeframe.MINUTE_15: 15, - Timeframe.MINUTE_30: 30, - Timeframe.HOUR_1: 60, - Timeframe.HOUR_4: 240, - Timeframe.DAY_1: 1440, - Timeframe.WEEK_1: 10080, - Timeframe.MONTH_1: 43200, - } - minutes = multipliers.get(timeframe, 60) * limit - start_time = end_time - timedelta(minutes=minutes) - - query = f""" - SELECT - t.symbol, - o.timestamp, - o.open, - o.high, - o.low, - o.close, - o.volume, - o.trades, - o.vwap - FROM market_data.tickers t - JOIN market_data.{table} o ON o.ticker_id = t.id - WHERE UPPER(t.symbol) = UPPER($1) - AND o.timestamp >= $2 - AND o.timestamp <= $3 - ORDER BY o.timestamp ASC - LIMIT $4 - """ - - rows = await conn.fetch(query, symbol, start_time, end_time, limit) - - if not rows: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"No candle data for symbol {symbol}" - ) - - candles = [ - OHLCV( - symbol=row["symbol"], - timeframe=timeframe, - timestamp=row["timestamp"], - open=Decimal(str(row["open"])), - high=Decimal(str(row["high"])), - low=Decimal(str(row["low"])), - close=Decimal(str(row["close"])), - volume=Decimal(str(row["volume"])), - trades=row["trades"], - vwap=Decimal(str(row["vwap"])) if row["vwap"] else None - ) - for row in rows - ] - - return CandlesResponse( - symbol=symbol.upper(), - timeframe=timeframe, - candles=candles, - count=len(candles) - ) - - -# ============================================================================= -# Order Book -# ============================================================================= - -@router.get("/api/v1/orderbook/{symbol}", response_model=OrderBook, tags=["Market Data"]) -async def get_orderbook( - symbol: str, - depth: int = Query(default=20, ge=1, le=100), - conn: asyncpg.Connection = Depends(get_db_connection), - _: None = Depends(check_rate_limit) -): - """ - Get order book snapshot for a symbol. - - Returns top bids and asks up to specified depth. - """ - # This would typically come from a live feed or cache - # For now, generate synthetic data based on last price - row = await conn.fetchrow( - """ - SELECT - t.symbol, - o.close as last_price, - t.tick_size, - o.timestamp - FROM market_data.tickers t - JOIN market_data.ohlcv_5min o ON o.ticker_id = t.id - WHERE UPPER(t.symbol) = UPPER($1) - ORDER BY o.timestamp DESC - LIMIT 1 - """, - symbol - ) - - if not row: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"No data for symbol {symbol}" - ) - - last_price = Decimal(str(row["last_price"])) - tick_size = Decimal(str(row["tick_size"])) if row["tick_size"] else Decimal("0.00001") - - # Generate synthetic orderbook (in production, this comes from exchange) - bids = [] - asks = [] - - for i in range(depth): - bid_price = last_price - (tick_size * (i + 1)) - ask_price = last_price + (tick_size * (i + 1)) - quantity = Decimal(str(1000 / (i + 1))) # Decreasing liquidity - - bids.append(OrderBookLevel(price=bid_price, quantity=quantity)) - asks.append(OrderBookLevel(price=ask_price, quantity=quantity)) - - return OrderBook( - symbol=row["symbol"], - timestamp=row["timestamp"], - bids=bids, - asks=asks - ) - - -# ============================================================================= -# Trades -# ============================================================================= - -@router.get("/api/v1/trades/{symbol}", response_model=List[Trade], tags=["Market Data"]) -async def get_trades( - symbol: str, - limit: int = Query(default=50, ge=1, le=500), - conn: asyncpg.Connection = Depends(get_db_connection), - _: None = Depends(check_rate_limit) -): - """ - Get recent trades for a symbol. - - Returns last N trades in descending time order. - """ - rows = await conn.fetch( - """ - SELECT - t.symbol, - tr.trade_id, - tr.price, - tr.quantity, - tr.side, - tr.timestamp - FROM market_data.tickers t - JOIN market_data.trades tr ON tr.ticker_id = t.id - WHERE UPPER(t.symbol) = UPPER($1) - ORDER BY tr.timestamp DESC - LIMIT $2 - """, - symbol, - limit - ) - - if not rows: - # Return empty list if no trades found - return [] - - return [ - Trade( - symbol=row["symbol"], - trade_id=row["trade_id"], - price=Decimal(str(row["price"])), - quantity=Decimal(str(row["quantity"])), - side=row["side"], - timestamp=row["timestamp"] - ) - for row in rows - ] - - -# ============================================================================= -# Admin / Management -# ============================================================================= - -@router.post("/api/v1/admin/backfill/{symbol}", tags=["Admin"]) -async def backfill_symbol( - symbol: str, - days: int = Query(default=30, ge=1, le=365), - asset_type: AssetType = AssetType.FOREX, - data_service = Depends(get_data_service) -): - """ - Trigger manual data backfill for a symbol. - - Admin endpoint to populate historical data. - """ - try: - rows = await data_service.backfill_ticker( - symbol=symbol, - days=days, - asset_type=asset_type.value - ) - return { - "status": "success", - "symbol": symbol, - "rows_inserted": rows, - "days": days - } - except ValueError as e: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(e) - ) - except Exception as e: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Backfill failed: {str(e)}" - ) - - -@router.post("/api/v1/admin/sync", tags=["Admin"]) -async def trigger_sync(data_service = Depends(get_data_service)): - """Trigger immediate data sync for all symbols.""" - import asyncio - asyncio.create_task(data_service.sync_all_tickers()) - return {"status": "sync_triggered"} diff --git a/apps/data-service/src/api/sync_routes.py b/apps/data-service/src/api/sync_routes.py deleted file mode 100644 index 3d893f1..0000000 --- a/apps/data-service/src/api/sync_routes.py +++ /dev/null @@ -1,331 +0,0 @@ -""" -Data Synchronization API Routes -OrbiQuant IA Trading Platform - Data Service - -Endpoints for managing data synchronization with Massive.com/Polygon.io -""" - -from datetime import datetime -from typing import Optional, List, Dict, Any -from fastapi import APIRouter, Depends, HTTPException, Query, status, BackgroundTasks -from pydantic import BaseModel, Field - -import asyncpg - -from providers.polygon_client import AssetType, Timeframe -from services.sync_service import DataSyncService, SyncStatus -from .dependencies import get_db_pool, get_polygon_client - - -router = APIRouter(prefix="/api/sync", tags=["Data Sync"]) - - -# ============================================================================= -# Request/Response Models -# ============================================================================= - -class SyncSymbolRequest(BaseModel): - """Request to sync a specific symbol.""" - asset_type: AssetType = Field(AssetType.FOREX, description="Asset type") - timeframe: Timeframe = Field(Timeframe.MINUTE_5, description="Timeframe to sync") - backfill_days: int = Field(30, ge=1, le=365, description="Days to backfill") - - -class SyncSymbolResponse(BaseModel): - """Response from sync operation.""" - status: str - symbol: str - timeframe: str - rows_inserted: int - start_date: Optional[str] = None - end_date: Optional[str] = None - error: Optional[str] = None - - -class SyncStatusResponse(BaseModel): - """Sync status for a ticker.""" - symbol: str - asset_type: str - timeframe: Optional[str] = None - last_sync: Optional[str] = None - rows_synced: Optional[int] = None - status: Optional[str] = None - error: Optional[str] = None - updated_at: Optional[str] = None - - -class SyncAllResponse(BaseModel): - """Response from syncing all tickers.""" - total_tickers: int - successful: int - failed: int - total_rows_inserted: int - message: str - - -class SymbolInfo(BaseModel): - """Information about a supported symbol.""" - symbol: str - polygon_symbol: str - mt4_symbol: Optional[str] = None - asset_type: str - pip_value: Optional[float] = None - supported: bool = True - - -class SymbolsListResponse(BaseModel): - """List of supported symbols.""" - symbols: List[SymbolInfo] - total: int - asset_types: List[str] - - -# ============================================================================= -# Dependency Functions -# ============================================================================= - -async def get_sync_service( - db_pool: asyncpg.Pool = Depends(get_db_pool), - polygon_client = Depends(get_polygon_client) -) -> DataSyncService: - """Get DataSyncService instance.""" - return DataSyncService( - polygon_client=polygon_client, - db_pool=db_pool, - batch_size=10000 - ) - - -# ============================================================================= -# Symbols Endpoints -# ============================================================================= - -@router.get("/symbols", response_model=SymbolsListResponse) -async def list_supported_symbols( - asset_type: Optional[AssetType] = Query(None, description="Filter by asset type"), - sync_service: DataSyncService = Depends(get_sync_service) -): - """ - Get list of symbols supported by Massive.com/Polygon.io. - - Returns all symbols configured in the system with their mappings. - Can be filtered by asset type (forex, crypto, index, stock). - """ - symbols = await sync_service.get_supported_symbols(asset_type=asset_type) - - # Get unique asset types - asset_types = list(set(s["asset_type"] for s in symbols)) - - return SymbolsListResponse( - symbols=[SymbolInfo(**s) for s in symbols], - total=len(symbols), - asset_types=sorted(asset_types) - ) - - -@router.get("/symbols/{symbol}") -async def get_symbol_info( - symbol: str, - sync_service: DataSyncService = Depends(get_sync_service) -): - """ - Get detailed information about a specific symbol. - - Includes sync status and configuration. - """ - # Get symbol from supported list - symbols = await sync_service.get_supported_symbols() - symbol_info = next((s for s in symbols if s["symbol"].upper() == symbol.upper()), None) - - if not symbol_info: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Symbol {symbol} not supported" - ) - - # Get sync status - sync_status = await sync_service.get_sync_status(symbol=symbol) - - return { - **symbol_info, - "sync_status": sync_status - } - - -# ============================================================================= -# Sync Control Endpoints -# ============================================================================= - -@router.post("/sync/{symbol}", response_model=SyncSymbolResponse) -async def sync_symbol( - symbol: str, - request: SyncSymbolRequest, - background_tasks: BackgroundTasks, - sync_service: DataSyncService = Depends(get_sync_service) -): - """ - Trigger data synchronization for a specific symbol. - - This will fetch historical data from Massive.com/Polygon.io and store it - in the database. The operation runs in the background and returns immediately. - - Parameters: - - **symbol**: Ticker symbol (e.g., 'EURUSD', 'BTCUSD') - - **asset_type**: Type of asset (forex, crypto, index, stock) - - **timeframe**: Data timeframe (1m, 5m, 15m, 1h, 4h, 1d) - - **backfill_days**: Number of days to backfill (1-365) - """ - # Validate symbol is supported - symbols = await sync_service.get_supported_symbols() - if not any(s["symbol"].upper() == symbol.upper() for s in symbols): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Symbol {symbol} not supported. Use /api/sync/symbols to see available symbols." - ) - - # Start sync in background - result = await sync_service.sync_ticker_data( - symbol=symbol.upper(), - asset_type=request.asset_type, - timeframe=request.timeframe, - backfill_days=request.backfill_days - ) - - return SyncSymbolResponse(**result) - - -@router.post("/sync-all", response_model=SyncAllResponse) -async def sync_all_symbols( - background_tasks: BackgroundTasks, - timeframe: Timeframe = Query(Timeframe.MINUTE_5, description="Timeframe to sync"), - backfill_days: int = Query(1, ge=1, le=30, description="Days to backfill"), - sync_service: DataSyncService = Depends(get_sync_service) -): - """ - Trigger synchronization for all active tickers. - - This is a heavy operation and may take a while depending on the number - of active tickers and the API rate limits. - - Only use this for initial setup or manual full sync. - """ - # Run sync in background - def run_sync(): - import asyncio - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - result = loop.run_until_complete( - sync_service.sync_all_active_tickers( - timeframe=timeframe, - backfill_days=backfill_days - ) - ) - loop.close() - return result - - background_tasks.add_task(run_sync) - - return SyncAllResponse( - total_tickers=0, - successful=0, - failed=0, - total_rows_inserted=0, - message="Sync started in background. Check /api/sync/status for progress." - ) - - -# ============================================================================= -# Status Endpoints -# ============================================================================= - -@router.get("/status", response_model=List[SyncStatusResponse]) -async def get_sync_status( - symbol: Optional[str] = Query(None, description="Filter by symbol"), - sync_service: DataSyncService = Depends(get_sync_service) -): - """ - Get synchronization status for all tickers or a specific symbol. - - Shows: - - Last sync timestamp - - Number of rows synced - - Sync status (success, failed, in_progress) - - Error messages if any - """ - status_list = await sync_service.get_sync_status(symbol=symbol) - - return [SyncStatusResponse(**s) for s in status_list] - - -@router.get("/status/{symbol}", response_model=List[SyncStatusResponse]) -async def get_symbol_sync_status( - symbol: str, - sync_service: DataSyncService = Depends(get_sync_service) -): - """ - Get detailed sync status for a specific symbol across all timeframes. - """ - status_list = await sync_service.get_sync_status(symbol=symbol) - - if not status_list: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"No sync status found for symbol {symbol}" - ) - - return [SyncStatusResponse(**s) for s in status_list] - - -# ============================================================================= -# Health Check -# ============================================================================= - -@router.get("/health") -async def sync_health_check( - polygon_client = Depends(get_polygon_client), - db_pool: asyncpg.Pool = Depends(get_db_pool) -): - """ - Check health of sync service and data providers. - - Verifies: - - Database connectivity - - Polygon/Massive API accessibility - - Rate limit status - """ - health = { - "status": "healthy", - "timestamp": datetime.utcnow().isoformat(), - "providers": {} - } - - # Check database - try: - async with db_pool.acquire() as conn: - await conn.fetchval("SELECT 1") - health["providers"]["database"] = { - "status": "connected", - "type": "PostgreSQL" - } - except Exception as e: - health["status"] = "unhealthy" - health["providers"]["database"] = { - "status": "error", - "error": str(e) - } - - # Check Polygon API (basic connectivity) - try: - health["providers"]["polygon"] = { - "status": "configured", - "base_url": polygon_client.base_url, - "rate_limit": f"{polygon_client.rate_limit} req/min" - } - except Exception as e: - health["status"] = "degraded" - health["providers"]["polygon"] = { - "status": "error", - "error": str(e) - } - - return health diff --git a/apps/data-service/src/app.py b/apps/data-service/src/app.py deleted file mode 100644 index d511aab..0000000 --- a/apps/data-service/src/app.py +++ /dev/null @@ -1,200 +0,0 @@ -""" -FastAPI Application -OrbiQuant IA Trading Platform - Data Service - -Main application entry point with REST API and WebSocket support. -""" - -import asyncio -import logging -import signal -from contextlib import asynccontextmanager -from datetime import datetime -from typing import Optional - -import asyncpg -from fastapi import FastAPI, Request -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse - -from config import Config -from api.routes import router as api_router -from websocket.handlers import WSRouter, set_ws_manager -from websocket.manager import WebSocketManager -from providers.polygon_client import PolygonClient -from providers.binance_client import BinanceClient - -# Logging setup -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' -) -logger = logging.getLogger(__name__) - - -@asynccontextmanager -async def lifespan(app: FastAPI): - """Application lifespan manager.""" - config = Config.from_env() - - # Store config - app.state.config = config - - # Initialize database pool - logger.info("Connecting to database...") - app.state.db_pool = await asyncpg.create_pool( - config.database.dsn, - min_size=config.database.min_connections, - max_size=config.database.max_connections - ) - logger.info("Database connection pool created") - - # Initialize Polygon client - if config.polygon.api_key: - app.state.polygon_client = PolygonClient( - api_key=config.polygon.api_key, - rate_limit_per_min=config.polygon.rate_limit_per_min, - base_url=config.polygon.base_url - ) - logger.info("Polygon client initialized") - else: - app.state.polygon_client = None - - # Initialize Binance client - import os - binance_key = os.getenv("BINANCE_API_KEY") - binance_secret = os.getenv("BINANCE_API_SECRET") - - if binance_key: - app.state.binance_client = BinanceClient( - api_key=binance_key, - api_secret=binance_secret, - testnet=os.getenv("BINANCE_TESTNET", "false").lower() == "true" - ) - logger.info("Binance client initialized") - else: - app.state.binance_client = None - - # Initialize WebSocket manager - ws_manager = WebSocketManager() - await ws_manager.start() - app.state.ws_manager = ws_manager - set_ws_manager(ws_manager) - logger.info("WebSocket manager started") - - # Store start time for uptime - app.state.start_time = datetime.utcnow() - - logger.info("Data Service started successfully") - - yield # Application runs here - - # Shutdown - logger.info("Shutting down Data Service...") - - await ws_manager.stop() - - if app.state.binance_client: - await app.state.binance_client.close() - - await app.state.db_pool.close() - - logger.info("Data Service shutdown complete") - - -def create_app() -> FastAPI: - """Create and configure FastAPI application.""" - app = FastAPI( - title="OrbiQuant Data Service", - description=""" - Market data service for the OrbiQuant IA Trading Platform. - - ## Features - - Real-time ticker prices - - Historical OHLCV data - - Order book snapshots - - WebSocket streaming - - Multi-provider support (Polygon, Binance, MT4) - - ## WebSocket Channels - - `ticker` - Real-time price updates - - `candles` - OHLCV candle updates - - `orderbook` - Order book snapshots - - `trades` - Recent trades - - `signals` - ML trading signals - """, - version="1.0.0", - docs_url="/docs", - redoc_url="/redoc", - lifespan=lifespan - ) - - # CORS middleware - app.add_middleware( - CORSMiddleware, - allow_origins=[ - "http://localhost:3000", - "http://localhost:3001", - "http://localhost:5173", - "https://orbiquant.com", - "https://*.orbiquant.com", - ], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], - ) - - # Global exception handler - @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": "Internal server error", - "detail": str(exc) if app.debug else "An unexpected error occurred", - "timestamp": datetime.utcnow().isoformat() - } - ) - - # Include routers - app.include_router(api_router) - - # MT4/MetaAPI routes - from api.mt4_routes import router as mt4_router - app.include_router(mt4_router) - - # WebSocket router - ws_router = WSRouter() - app.include_router(ws_router.router, tags=["WebSocket"]) - - # Root endpoint - @app.get("/", tags=["Root"]) - async def root(): - return { - "service": "OrbiQuant Data Service", - "version": "1.0.0", - "status": "running", - "docs": "/docs", - "health": "/health", - "websocket": "/ws/stream", - "mt4": "/api/mt4/status" - } - - return app - - -# Create application instance -app = create_app() - - -if __name__ == "__main__": - import uvicorn - - uvicorn.run( - "app:app", - host="0.0.0.0", - port=8001, - reload=True, - log_level="info" - ) diff --git a/apps/data-service/src/app_updated.py b/apps/data-service/src/app_updated.py deleted file mode 100644 index 764c029..0000000 --- a/apps/data-service/src/app_updated.py +++ /dev/null @@ -1,282 +0,0 @@ -""" -FastAPI Application -OrbiQuant IA Trading Platform - Data Service - -Main application entry point with REST API, WebSocket support, and automatic data sync. - -UPDATED: Now includes Massive.com integration and automatic sync scheduler -""" - -import asyncio -import logging -import signal -from contextlib import asynccontextmanager -from datetime import datetime -from typing import Optional - -import asyncpg -from fastapi import FastAPI, Request -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse - -from config import Config -from api.routes import router as api_router -from api.sync_routes import router as sync_router -from websocket.handlers import WSRouter, set_ws_manager -from websocket.manager import WebSocketManager -from providers.polygon_client import PolygonClient -from providers.binance_client import BinanceClient -from services.sync_service import DataSyncService -from services.scheduler import SchedulerManager - -# Logging setup -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' -) -logger = logging.getLogger(__name__) - - -@asynccontextmanager -async def lifespan(app: FastAPI): - """Application lifespan manager.""" - config = Config.from_env() - - # Store config - app.state.config = config - - # Initialize database pool - logger.info("Connecting to database...") - app.state.db_pool = await asyncpg.create_pool( - config.database.dsn, - min_size=config.database.min_connections, - max_size=config.database.max_connections - ) - logger.info("Database connection pool created") - - # Initialize Polygon client - if config.polygon.api_key: - app.state.polygon_client = PolygonClient( - api_key=config.polygon.api_key, - rate_limit_per_min=config.polygon.rate_limit_per_min, - base_url=config.polygon.base_url, - use_massive_url=config.polygon.base_url == "https://api.massive.com" - ) - logger.info(f"Polygon/Massive client initialized - URL: {config.polygon.base_url}") - else: - app.state.polygon_client = None - logger.warning("Polygon/Massive client not initialized - API key missing") - - # Initialize Binance client - import os - binance_key = os.getenv("BINANCE_API_KEY") - binance_secret = os.getenv("BINANCE_API_SECRET") - - if binance_key: - app.state.binance_client = BinanceClient( - api_key=binance_key, - api_secret=binance_secret, - testnet=os.getenv("BINANCE_TESTNET", "false").lower() == "true" - ) - logger.info("Binance client initialized") - else: - app.state.binance_client = None - - # Initialize WebSocket manager - ws_manager = WebSocketManager() - await ws_manager.start() - app.state.ws_manager = ws_manager - set_ws_manager(ws_manager) - logger.info("WebSocket manager started") - - # Initialize sync service and scheduler - if app.state.polygon_client: - app.state.sync_service = DataSyncService( - polygon_client=app.state.polygon_client, - db_pool=app.state.db_pool - ) - logger.info("Data sync service initialized") - - # Start scheduler for automatic sync - enable_scheduler = os.getenv("ENABLE_SYNC_SCHEDULER", "true").lower() == "true" - if enable_scheduler: - app.state.scheduler = await SchedulerManager.get_instance( - sync_service=app.state.sync_service, - sync_interval_minutes=config.sync_interval_minutes - ) - logger.info("Data sync scheduler started") - else: - app.state.scheduler = None - logger.info("Sync scheduler disabled") - else: - app.state.sync_service = None - app.state.scheduler = None - logger.warning("Sync service and scheduler not initialized") - - # Store start time for uptime - app.state.start_time = datetime.utcnow() - - logger.info("Data Service started successfully") - - yield # Application runs here - - # Shutdown - logger.info("Shutting down Data Service...") - - # Stop scheduler - if app.state.scheduler: - await SchedulerManager.stop_instance() - logger.info("Scheduler stopped") - - await ws_manager.stop() - - if app.state.binance_client: - await app.state.binance_client.close() - - if app.state.polygon_client and hasattr(app.state.polygon_client, '_session'): - if app.state.polygon_client._session: - await app.state.polygon_client._session.close() - - await app.state.db_pool.close() - - logger.info("Data Service shutdown complete") - - -def create_app() -> FastAPI: - """Create and configure FastAPI application.""" - app = FastAPI( - title="OrbiQuant Data Service", - description=""" - Market data service for the OrbiQuant IA Trading Platform. - - ## Features - - Real-time ticker prices - - Historical OHLCV data (multiple timeframes) - - Order book snapshots - - WebSocket streaming - - Multi-provider support (Polygon/Massive, Binance, MT4) - - Automatic data synchronization - - Scheduled background sync tasks - - ## Data Providers - - **Massive.com/Polygon.io**: Forex, Crypto, Indices, Stocks - - **Binance**: Crypto markets - - **MT4**: Forex and CFDs - - ## WebSocket Channels - - `ticker` - Real-time price updates - - `candles` - OHLCV candle updates - - `orderbook` - Order book snapshots - - `trades` - Recent trades - - `signals` - ML trading signals - - ## Sync Endpoints - - `/api/sync/symbols` - List supported symbols - - `/api/sync/sync/{symbol}` - Sync specific symbol - - `/api/sync/status` - Get sync status - """, - version="2.0.0", - docs_url="/docs", - redoc_url="/redoc", - lifespan=lifespan - ) - - # CORS middleware - app.add_middleware( - CORSMiddleware, - allow_origins=[ - "http://localhost:3000", - "http://localhost:3001", - "http://localhost:5173", - "https://orbiquant.com", - "https://*.orbiquant.com", - ], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], - ) - - # Global exception handler - @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": "Internal server error", - "detail": str(exc) if app.debug else "An unexpected error occurred", - "timestamp": datetime.utcnow().isoformat() - } - ) - - # Include routers - app.include_router(api_router, tags=["Market Data"]) - app.include_router(sync_router, tags=["Data Sync"]) - - # WebSocket router - ws_router = WSRouter() - app.include_router(ws_router.router, tags=["WebSocket"]) - - # Root endpoint - @app.get("/", tags=["Root"]) - async def root(): - uptime = None - if hasattr(app.state, 'start_time'): - uptime = (datetime.utcnow() - app.state.start_time).total_seconds() - - return { - "service": "OrbiQuant Data Service", - "version": "2.0.0", - "status": "running", - "uptime_seconds": uptime, - "features": { - "polygon_massive": hasattr(app.state, 'polygon_client') and app.state.polygon_client is not None, - "binance": hasattr(app.state, 'binance_client') and app.state.binance_client is not None, - "auto_sync": hasattr(app.state, 'scheduler') and app.state.scheduler is not None, - "websocket": True - }, - "endpoints": { - "docs": "/docs", - "health": "/health", - "websocket": "/ws/stream", - "symbols": "/api/sync/symbols", - "sync_status": "/api/sync/status" - } - } - - # Scheduler status endpoint - @app.get("/scheduler/status", tags=["Scheduler"]) - async def scheduler_status(): - """Get scheduler status and job list.""" - if not hasattr(app.state, 'scheduler') or not app.state.scheduler: - return { - "enabled": False, - "message": "Scheduler is disabled" - } - - jobs = app.state.scheduler.get_jobs() - - return { - "enabled": True, - "running": app.state.scheduler._is_running, - "jobs": jobs, - "total_jobs": len(jobs) - } - - return app - - -# Create application instance -app = create_app() - - -if __name__ == "__main__": - import uvicorn - - uvicorn.run( - "app:app", - host="0.0.0.0", - port=8001, - reload=True, - log_level="info" - ) diff --git a/apps/data-service/src/config.py b/apps/data-service/src/config.py deleted file mode 100644 index 109cc5e..0000000 --- a/apps/data-service/src/config.py +++ /dev/null @@ -1,169 +0,0 @@ -""" -Configuration for Data Service -OrbiQuant IA Trading Platform -""" - -import os -from dataclasses import dataclass -from typing import Optional - - -@dataclass -class DatabaseConfig: - """PostgreSQL configuration.""" - host: str = "localhost" - port: int = 5432 - database: str = "orbiquant_trading" - user: str = "orbiquant_user" - password: str = "orbiquant_dev_2025" - min_connections: int = 5 - max_connections: int = 20 - - @property - def dsn(self) -> str: - return f"postgresql://{self.user}:{self.password}@{self.host}:{self.port}/{self.database}" - - -@dataclass -class PolygonConfig: - """Polygon.io / Massive.com API configuration.""" - api_key: str = "" - base_url: str = "https://api.polygon.io" - rate_limit_per_min: int = 5 # Basic tier - subscription_tier: str = "basic" # basic, starter, advanced - - @classmethod - def from_env(cls) -> "PolygonConfig": - return cls( - api_key=os.getenv("POLYGON_API_KEY", ""), - base_url=os.getenv("POLYGON_BASE_URL", "https://api.polygon.io"), - rate_limit_per_min=int(os.getenv("POLYGON_RATE_LIMIT", "5")), - subscription_tier=os.getenv("POLYGON_TIER", "basic"), - ) - - -@dataclass -class MetaAPIConfig: - """MetaAPI.cloud configuration for MT4/MT5 access.""" - token: str = "" - account_id: str = "" - - @classmethod - def from_env(cls) -> "MetaAPIConfig": - return cls( - token=os.getenv("METAAPI_TOKEN", ""), - account_id=os.getenv("METAAPI_ACCOUNT_ID", ""), - ) - - -@dataclass -class MT4DirectConfig: - """Direct MT4 server connection configuration.""" - server: str = "" - login: int = 0 - password: str = "" - investor_mode: bool = True # Default to read-only - - @classmethod - def from_env(cls) -> "MT4DirectConfig": - return cls( - server=os.getenv("MT4_SERVER", ""), - login=int(os.getenv("MT4_LOGIN", "0")), - password=os.getenv("MT4_PASSWORD", ""), - investor_mode=os.getenv("MT4_INVESTOR_MODE", "true").lower() == "true", - ) - - -@dataclass -class SpreadConfig: - """Spread calculation configuration.""" - # Default spreads by asset type (in price units) - default_forex_major: float = 0.00010 # 1 pip - default_forex_minor: float = 0.00020 # 2 pips - default_forex_exotic: float = 0.00050 # 5 pips - default_crypto: float = 0.001 # 0.1% - default_index: float = 0.5 # 0.5 points - default_commodity: float = 0.05 - - # Session multipliers - asian_mult: float = 1.3 - london_mult: float = 0.9 - newyork_mult: float = 0.95 - overlap_mult: float = 0.85 - pacific_mult: float = 1.2 - - # Volatility multipliers - high_vol_mult: float = 1.5 - low_vol_mult: float = 1.0 - - -@dataclass -class Config: - """Main configuration.""" - database: DatabaseConfig - polygon: PolygonConfig - metaapi: MetaAPIConfig - mt4_direct: MT4DirectConfig - spread: SpreadConfig - - # Sync settings - sync_interval_minutes: int = 5 - backfill_days: int = 30 - - @classmethod - def from_env(cls) -> "Config": - return cls( - database=DatabaseConfig( - host=os.getenv("DB_HOST", "localhost"), - port=int(os.getenv("DB_PORT", "5432")), - database=os.getenv("DB_NAME", "orbiquant_trading"), - user=os.getenv("DB_USER", "orbiquant_user"), - password=os.getenv("DB_PASSWORD", "orbiquant_dev_2025"), - ), - polygon=PolygonConfig.from_env(), - metaapi=MetaAPIConfig.from_env(), - mt4_direct=MT4DirectConfig.from_env(), - spread=SpreadConfig(), - sync_interval_minutes=int(os.getenv("SYNC_INTERVAL_MINUTES", "5")), - backfill_days=int(os.getenv("BACKFILL_DAYS", "30")), - ) - - -# Ticker symbol mappings -TICKER_MAPPINGS = { - # Forex pairs - Polygon prefix C: - "EURUSD": {"polygon": "C:EURUSD", "mt4": "EURUSD", "mt4_micro": "EURUSDm", "pip_value": 0.0001}, - "GBPUSD": {"polygon": "C:GBPUSD", "mt4": "GBPUSD", "mt4_micro": "GBPUSDm", "pip_value": 0.0001}, - "USDJPY": {"polygon": "C:USDJPY", "mt4": "USDJPY", "mt4_micro": "USDJPYm", "pip_value": 0.01}, - "USDCAD": {"polygon": "C:USDCAD", "mt4": "USDCAD", "mt4_micro": "USDCADm", "pip_value": 0.0001}, - "AUDUSD": {"polygon": "C:AUDUSD", "mt4": "AUDUSD", "mt4_micro": "AUDUSDm", "pip_value": 0.0001}, - "NZDUSD": {"polygon": "C:NZDUSD", "mt4": "NZDUSD", "mt4_micro": "NZDUSDm", "pip_value": 0.0001}, - "EURGBP": {"polygon": "C:EURGBP", "mt4": "EURGBP", "mt4_micro": "EURGBPm", "pip_value": 0.0001}, - "EURAUD": {"polygon": "C:EURAUD", "mt4": "EURAUD", "mt4_micro": "EURAUDm", "pip_value": 0.0001}, - "EURCHF": {"polygon": "C:EURCHF", "mt4": "EURCHF", "mt4_micro": "EURCHFm", "pip_value": 0.0001}, - "GBPJPY": {"polygon": "C:GBPJPY", "mt4": "GBPJPY", "mt4_micro": "GBPJPYm", "pip_value": 0.01}, - "GBPAUD": {"polygon": "C:GBPAUD", "mt4": "GBPAUD", "mt4_micro": "GBPAUDm", "pip_value": 0.0001}, - "GBPCAD": {"polygon": "C:GBPCAD", "mt4": "GBPCAD", "mt4_micro": "GBPCADm", "pip_value": 0.0001}, - "GBPNZD": {"polygon": "C:GBPNZD", "mt4": "GBPNZD", "mt4_micro": "GBPNZDm", "pip_value": 0.0001}, - "AUDCAD": {"polygon": "C:AUDCAD", "mt4": "AUDCAD", "mt4_micro": "AUDCADm", "pip_value": 0.0001}, - "AUDCHF": {"polygon": "C:AUDCHF", "mt4": "AUDCHF", "mt4_micro": "AUDCHFm", "pip_value": 0.0001}, - "AUDNZD": {"polygon": "C:AUDNZD", "mt4": "AUDNZD", "mt4_micro": "AUDNZDm", "pip_value": 0.0001}, - - # Commodities - "XAUUSD": {"polygon": "C:XAUUSD", "mt4": "XAUUSD", "mt4_micro": "XAUUSDm", "pip_value": 0.01}, - "XAGUSD": {"polygon": "C:XAGUSD", "mt4": "XAGUSD", "mt4_micro": "XAGUSDm", "pip_value": 0.001}, - - # Crypto - Polygon prefix X: - "BTCUSD": {"polygon": "X:BTCUSD", "mt4": "BTCUSD", "mt4_micro": "BTCUSDm", "pip_value": 1.0}, - - # Indices - Polygon prefix I: - "SPX500": {"polygon": "I:SPX", "mt4": "US500", "mt4_micro": "US500m", "pip_value": 0.1}, - "NAS100": {"polygon": "I:NDX", "mt4": "US100", "mt4_micro": "US100m", "pip_value": 0.1}, - "DJI30": {"polygon": "I:DJI", "mt4": "US30", "mt4_micro": "US30m", "pip_value": 0.1}, - "DAX40": {"polygon": "I:DAX", "mt4": "DE40", "mt4_micro": "DE40m", "pip_value": 0.1}, -} - -# Asset type classification -FOREX_MAJORS = ["EURUSD", "GBPUSD", "USDJPY", "USDCHF", "AUDUSD", "NZDUSD", "USDCAD"] -FOREX_MINORS = ["EURGBP", "EURAUD", "EURCHF", "GBPJPY", "GBPAUD", "EURJPY", "AUDJPY"] -FOREX_CROSSES = ["GBPCAD", "GBPNZD", "AUDCAD", "AUDCHF", "AUDNZD"] diff --git a/apps/data-service/src/main.py b/apps/data-service/src/main.py deleted file mode 100644 index ec8bef3..0000000 --- a/apps/data-service/src/main.py +++ /dev/null @@ -1,366 +0,0 @@ -""" -Data Service Main Entry Point -OrbiQuant IA Trading Platform - -Provides: -1. Scheduled data sync from Polygon/Massive API -2. Real-time price monitoring from MT4 -3. Spread tracking and analysis -4. Price adjustment model training -""" - -import asyncio -import logging -import signal -from datetime import datetime, timedelta -from typing import Optional - -import asyncpg -from apscheduler.schedulers.asyncio import AsyncIOScheduler -from apscheduler.triggers.interval import IntervalTrigger -from apscheduler.triggers.cron import CronTrigger - -from config import Config, TICKER_MAPPINGS -from providers.polygon_client import PolygonClient, DataSyncService, AssetType, Timeframe -from providers.mt4_client import MetaAPIClient, SpreadTracker -from services.price_adjustment import PriceAdjustmentService - -# Setup logging -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' -) -logger = logging.getLogger(__name__) - - -class DataService: - """ - Main data service orchestrator. - - Handles: - - Periodic data synchronization from Polygon - - Real-time spread monitoring from MT4 - - Price adjustment model updates - """ - - def __init__(self, config: Config): - self.config = config - self.db_pool: Optional[asyncpg.Pool] = None - self.polygon_client: Optional[PolygonClient] = None - self.mt4_client: Optional[MetaAPIClient] = None - self.scheduler = AsyncIOScheduler() - - self._shutdown = False - - async def start(self): - """Initialize and start the service.""" - logger.info("Starting Data Service...") - - # Connect to database - self.db_pool = await asyncpg.create_pool( - self.config.database.dsn, - min_size=self.config.database.min_connections, - max_size=self.config.database.max_connections - ) - logger.info("Database connection pool created") - - # Initialize Polygon client - if self.config.polygon.api_key: - self.polygon_client = PolygonClient( - api_key=self.config.polygon.api_key, - rate_limit_per_min=self.config.polygon.rate_limit_per_min, - base_url=self.config.polygon.base_url - ) - logger.info("Polygon client initialized") - - # Initialize MT4/MetaAPI client - if self.config.metaapi.token: - self.mt4_client = MetaAPIClient( - token=self.config.metaapi.token, - account_id=self.config.metaapi.account_id - ) - await self.mt4_client.connect() - logger.info("MetaAPI client connected") - - # Initialize services - self.sync_service = DataSyncService(self.polygon_client, self.db_pool) if self.polygon_client else None - self.spread_tracker = SpreadTracker(self.db_pool) - self.price_adjustment = PriceAdjustmentService(self.db_pool) - - # Setup scheduled jobs - self._setup_jobs() - - # Start scheduler - self.scheduler.start() - logger.info("Scheduler started") - - # Keep running - while not self._shutdown: - await asyncio.sleep(1) - - async def stop(self): - """Gracefully stop the service.""" - logger.info("Stopping Data Service...") - self._shutdown = True - - self.scheduler.shutdown() - - if self.mt4_client: - await self.mt4_client.disconnect() - - if self.db_pool: - await self.db_pool.close() - - logger.info("Data Service stopped") - - def _setup_jobs(self): - """Configure scheduled jobs.""" - # Sync market data every 5 minutes during market hours - self.scheduler.add_job( - self.sync_all_tickers, - IntervalTrigger(minutes=self.config.sync_interval_minutes), - id="sync_market_data", - name="Sync market data from Polygon" - ) - - # Track spreads every minute if MT4 connected - if self.mt4_client: - self.scheduler.add_job( - self.track_spreads, - IntervalTrigger(minutes=1), - id="track_spreads", - name="Track broker spreads" - ) - - # Update spread statistics hourly - self.scheduler.add_job( - self.update_spread_statistics, - CronTrigger(minute=0), - id="update_spread_stats", - name="Update spread statistics" - ) - - # Train price adjustment models daily at 00:00 UTC - self.scheduler.add_job( - self.train_adjustment_models, - CronTrigger(hour=0, minute=0), - id="train_adjustment", - name="Train price adjustment models" - ) - - logger.info("Scheduled jobs configured") - - async def sync_all_tickers(self): - """Sync data for all configured tickers.""" - if not self.sync_service: - logger.warning("Polygon client not configured, skipping sync") - return - - logger.info("Starting market data sync...") - - async with self.db_pool.acquire() as conn: - tickers = await conn.fetch( - """ - SELECT t.id, t.symbol, t.asset_type, tm.provider_symbol - FROM market_data.tickers t - JOIN data_sources.ticker_mapping tm ON tm.ticker_id = t.id - JOIN data_sources.api_providers ap ON ap.id = tm.provider_id - WHERE t.is_active = true - AND ap.provider_name = 'polygon' - AND tm.is_active = true - """ - ) - - for ticker in tickers: - try: - asset_type = AssetType(ticker["asset_type"]) - rows = await self.sync_service.sync_ticker_data( - ticker_id=ticker["id"], - symbol=ticker["symbol"], - asset_type=asset_type, - timeframe=Timeframe.MINUTE_5 - ) - logger.info(f"Synced {rows} rows for {ticker['symbol']}") - - except Exception as e: - logger.error(f"Error syncing {ticker['symbol']}: {e}") - - logger.info("Market data sync completed") - - async def track_spreads(self): - """Record current spreads from broker.""" - if not self.mt4_client: - return - - async with self.db_pool.acquire() as conn: - tickers = await conn.fetch( - """ - SELECT t.id, t.symbol - FROM market_data.tickers t - WHERE t.is_active = true - AND t.asset_type IN ('forex', 'commodity') - """ - ) - - for ticker in tickers: - try: - # Get MT4 symbol mapping - mapping = TICKER_MAPPINGS.get(ticker["symbol"]) - if not mapping: - continue - - mt4_symbol = mapping.get("mt4", ticker["symbol"]) - tick = await self.mt4_client.get_tick(mt4_symbol) - - if tick: - await self.spread_tracker.record_spread( - account_id=1, # Default account - ticker_id=ticker["id"], - bid=tick.bid, - ask=tick.ask, - timestamp=tick.timestamp - ) - - except Exception as e: - logger.debug(f"Error tracking spread for {ticker['symbol']}: {e}") - - async def update_spread_statistics(self): - """Calculate and store spread statistics.""" - logger.info("Updating spread statistics...") - - async with self.db_pool.acquire() as conn: - # Get all tickers with spread data - tickers = await conn.fetch( - """ - SELECT DISTINCT ticker_id, account_id - FROM broker_integration.broker_prices - WHERE timestamp > NOW() - INTERVAL '24 hours' - """ - ) - - for row in tickers: - try: - stats = await self.spread_tracker.calculate_spread_statistics( - account_id=row["account_id"], - ticker_id=row["ticker_id"], - period_hours=24 - ) - - if stats and stats.get("sample_count", 0) > 10: - # Determine session - hour = datetime.utcnow().hour - session = self.price_adjustment.get_current_session().value - - async with self.db_pool.acquire() as conn: - await conn.execute( - """ - INSERT INTO broker_integration.spread_statistics - (account_id, ticker_id, period_start, period_end, session_type, - avg_spread, min_spread, max_spread, median_spread, std_spread, - spread_p95, sample_count) - VALUES ($1, $2, NOW() - INTERVAL '24 hours', NOW(), $3, - $4, $5, $6, $7, $8, $9, $10) - ON CONFLICT (account_id, ticker_id, period_start, session_type) - DO UPDATE SET - avg_spread = EXCLUDED.avg_spread, - sample_count = EXCLUDED.sample_count - """, - row["account_id"], row["ticker_id"], session, - stats.get("avg_spread"), stats.get("min_spread"), - stats.get("max_spread"), stats.get("median_spread"), - stats.get("std_spread"), stats.get("p95_spread"), - stats.get("sample_count") - ) - - except Exception as e: - logger.error(f"Error updating spread stats: {e}") - - logger.info("Spread statistics updated") - - async def train_adjustment_models(self): - """Train price adjustment models for all tickers.""" - logger.info("Training price adjustment models...") - - async with self.db_pool.acquire() as conn: - tickers = await conn.fetch( - """ - SELECT DISTINCT bp.ticker_id, bp.account_id - FROM broker_integration.broker_prices bp - WHERE bp.timestamp > NOW() - INTERVAL '7 days' - GROUP BY bp.ticker_id, bp.account_id - HAVING COUNT(*) > 1000 - """ - ) - - for row in tickers: - try: - params = await self.price_adjustment.train_adjustment_model( - ticker_id=row["ticker_id"], - account_id=row["account_id"], - days_of_data=30 - ) - logger.info(f"Trained model {params.model_version} for ticker {row['ticker_id']}") - - except Exception as e: - logger.error(f"Error training model for ticker {row['ticker_id']}: {e}") - - logger.info("Price adjustment model training completed") - - async def backfill_ticker( - self, - symbol: str, - days: int = 30, - asset_type: str = "forex" - ): - """Manually backfill data for a specific ticker.""" - if not self.sync_service: - raise ValueError("Polygon client not configured") - - async with self.db_pool.acquire() as conn: - ticker = await conn.fetchrow( - "SELECT id FROM market_data.tickers WHERE symbol = $1", - symbol - ) - - if not ticker: - raise ValueError(f"Ticker {symbol} not found") - - start_date = datetime.utcnow() - timedelta(days=days) - end_date = datetime.utcnow() - - rows = await self.sync_service.sync_ticker_data( - ticker_id=ticker["id"], - symbol=symbol, - asset_type=AssetType(asset_type), - start_date=start_date, - end_date=end_date, - timeframe=Timeframe.MINUTE_5 - ) - - logger.info(f"Backfilled {rows} rows for {symbol}") - return rows - - -async def main(): - """Main entry point.""" - config = Config.from_env() - service = DataService(config) - - # Handle shutdown signals - loop = asyncio.get_event_loop() - - def shutdown_handler(): - asyncio.create_task(service.stop()) - - for sig in (signal.SIGTERM, signal.SIGINT): - loop.add_signal_handler(sig, shutdown_handler) - - try: - await service.start() - except Exception as e: - logger.error(f"Service error: {e}") - await service.stop() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/apps/data-service/src/models/market.py b/apps/data-service/src/models/market.py deleted file mode 100644 index 1df4ca1..0000000 --- a/apps/data-service/src/models/market.py +++ /dev/null @@ -1,257 +0,0 @@ -""" -Market Data Models -OrbiQuant IA Trading Platform - Data Service -""" - -from datetime import datetime -from decimal import Decimal -from enum import Enum -from typing import Optional, List -from pydantic import BaseModel, Field - - -class AssetType(str, Enum): - """Asset type classification""" - CRYPTO = "crypto" - FOREX = "forex" - STOCK = "stock" - INDEX = "index" - COMMODITY = "commodity" - FUTURES = "futures" - - -class Timeframe(str, Enum): - """Supported timeframes""" - MINUTE_1 = "1m" - MINUTE_5 = "5m" - MINUTE_15 = "15m" - MINUTE_30 = "30m" - HOUR_1 = "1h" - HOUR_4 = "4h" - DAY_1 = "1d" - WEEK_1 = "1w" - MONTH_1 = "1M" - - -class SymbolStatus(str, Enum): - """Symbol trading status""" - TRADING = "trading" - HALTED = "halted" - BREAK = "break" - AUCTION = "auction" - - -# ============================================================================= -# Market Data Models -# ============================================================================= - -class Ticker(BaseModel): - """Real-time ticker data""" - symbol: str - price: Decimal - bid: Optional[Decimal] = None - ask: Optional[Decimal] = None - volume: Optional[Decimal] = None - change_24h: Optional[Decimal] = None - change_percent_24h: Optional[Decimal] = None - high_24h: Optional[Decimal] = None - low_24h: Optional[Decimal] = None - timestamp: datetime - - class Config: - json_encoders = { - Decimal: lambda v: float(v), - datetime: lambda v: v.isoformat() - } - - -class OHLCV(BaseModel): - """Candlestick/OHLCV data""" - symbol: str - timeframe: Timeframe - timestamp: datetime - open: Decimal - high: Decimal - low: Decimal - close: Decimal - volume: Decimal - trades: Optional[int] = None - vwap: Optional[Decimal] = None - - class Config: - json_encoders = { - Decimal: lambda v: float(v), - datetime: lambda v: v.isoformat() - } - - -class OrderBookLevel(BaseModel): - """Single order book level""" - price: Decimal - quantity: Decimal - - -class OrderBook(BaseModel): - """Order book snapshot""" - symbol: str - timestamp: datetime - bids: List[OrderBookLevel] - asks: List[OrderBookLevel] - - @property - def spread(self) -> Optional[Decimal]: - if self.bids and self.asks: - return self.asks[0].price - self.bids[0].price - return None - - @property - def mid_price(self) -> Optional[Decimal]: - if self.bids and self.asks: - return (self.asks[0].price + self.bids[0].price) / 2 - return None - - -class Trade(BaseModel): - """Individual trade""" - symbol: str - trade_id: str - price: Decimal - quantity: Decimal - side: str # "buy" or "sell" - timestamp: datetime - - -# ============================================================================= -# Symbol Information -# ============================================================================= - -class SymbolInfo(BaseModel): - """Symbol/instrument information""" - symbol: str - name: str - asset_type: AssetType - base_currency: str - quote_currency: str - exchange: str - status: SymbolStatus = SymbolStatus.TRADING - - # Precision - price_precision: int = 8 - quantity_precision: int = 8 - - # Limits - min_quantity: Optional[Decimal] = None - max_quantity: Optional[Decimal] = None - min_notional: Optional[Decimal] = None - - # Trading info - tick_size: Optional[Decimal] = None - lot_size: Optional[Decimal] = None - - # Metadata - is_active: bool = True - created_at: Optional[datetime] = None - updated_at: Optional[datetime] = None - - -# ============================================================================= -# API Request/Response Models -# ============================================================================= - -class TickerRequest(BaseModel): - """Request for ticker data""" - symbol: str - - -class CandlesRequest(BaseModel): - """Request for historical candles""" - symbol: str - timeframe: Timeframe = Timeframe.HOUR_1 - start_time: Optional[datetime] = None - end_time: Optional[datetime] = None - limit: int = Field(default=100, ge=1, le=1000) - - -class CandlesResponse(BaseModel): - """Response with candle data""" - symbol: str - timeframe: Timeframe - candles: List[OHLCV] - count: int - - -class TickersResponse(BaseModel): - """Response with multiple tickers""" - tickers: List[Ticker] - timestamp: datetime - - -class SymbolsResponse(BaseModel): - """Response with symbol list""" - symbols: List[SymbolInfo] - total: int - - -# ============================================================================= -# WebSocket Models -# ============================================================================= - -class WSSubscription(BaseModel): - """WebSocket subscription request""" - action: str # "subscribe" or "unsubscribe" - channel: str # "ticker", "candles", "orderbook", "trades" - symbols: List[str] - timeframe: Optional[Timeframe] = None # For candles - - -class WSMessage(BaseModel): - """WebSocket message wrapper""" - type: str - channel: str - symbol: Optional[str] = None - data: dict - timestamp: datetime = Field(default_factory=datetime.utcnow) - - -class WSTickerUpdate(BaseModel): - """WebSocket ticker update""" - symbol: str - price: Decimal - bid: Optional[Decimal] = None - ask: Optional[Decimal] = None - volume_24h: Optional[Decimal] = None - change_24h: Optional[Decimal] = None - timestamp: datetime - - -class WSCandleUpdate(BaseModel): - """WebSocket candle update""" - symbol: str - timeframe: Timeframe - candle: OHLCV - is_closed: bool = False - - -# ============================================================================= -# Health & Status -# ============================================================================= - -class ProviderStatus(BaseModel): - """Data provider status""" - name: str - is_connected: bool - latency_ms: Optional[float] = None - last_update: Optional[datetime] = None - error: Optional[str] = None - - -class ServiceHealth(BaseModel): - """Service health status""" - status: str # "healthy", "degraded", "unhealthy" - version: str - uptime_seconds: float - providers: List[ProviderStatus] - database_connected: bool - cache_connected: bool - websocket_clients: int - timestamp: datetime = Field(default_factory=datetime.utcnow) diff --git a/apps/data-service/src/providers/__init__.py b/apps/data-service/src/providers/__init__.py deleted file mode 100644 index a7f5cf1..0000000 --- a/apps/data-service/src/providers/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Data providers module.""" - -from .polygon_client import PolygonClient, DataSyncService, AssetType, Timeframe, OHLCVBar -from .mt4_client import MT4Client, MetaAPIClient, SpreadTracker, MT4Tick, MT4Order - -__all__ = [ - "PolygonClient", - "DataSyncService", - "AssetType", - "Timeframe", - "OHLCVBar", - "MT4Client", - "MetaAPIClient", - "SpreadTracker", - "MT4Tick", - "MT4Order", -] diff --git a/apps/data-service/src/providers/binance_client.py b/apps/data-service/src/providers/binance_client.py deleted file mode 100644 index 95c39d1..0000000 --- a/apps/data-service/src/providers/binance_client.py +++ /dev/null @@ -1,562 +0,0 @@ -""" -Binance API Client -OrbiQuant IA Trading Platform - Data Service - -Provides real-time and historical market data from Binance. -""" - -import asyncio -import hashlib -import hmac -import logging -import time -from datetime import datetime, timedelta -from decimal import Decimal -from typing import Any, Dict, List, Optional, Callable -from urllib.parse import urlencode - -import aiohttp -from aiohttp import ClientTimeout - -from models.market import ( - Ticker, OHLCV, OrderBook, OrderBookLevel, Trade, - Timeframe, AssetType, SymbolInfo, SymbolStatus -) - -logger = logging.getLogger(__name__) - - -# Timeframe mapping to Binance intervals -TIMEFRAME_MAP = { - Timeframe.MINUTE_1: "1m", - Timeframe.MINUTE_5: "5m", - Timeframe.MINUTE_15: "15m", - Timeframe.MINUTE_30: "30m", - Timeframe.HOUR_1: "1h", - Timeframe.HOUR_4: "4h", - Timeframe.DAY_1: "1d", - Timeframe.WEEK_1: "1w", - Timeframe.MONTH_1: "1M", -} - - -class BinanceClient: - """ - Async Binance API client. - - Supports both REST API and WebSocket streams. - """ - - BASE_URL = "https://api.binance.com" - WS_URL = "wss://stream.binance.com:9443/ws" - TESTNET_URL = "https://testnet.binance.vision" - TESTNET_WS_URL = "wss://testnet.binance.vision/ws" - - def __init__( - self, - api_key: Optional[str] = None, - api_secret: Optional[str] = None, - testnet: bool = False, - rate_limit_per_min: int = 1200 - ): - self.api_key = api_key - self.api_secret = api_secret - self.testnet = testnet - - self.base_url = self.TESTNET_URL if testnet else self.BASE_URL - self.ws_url = self.TESTNET_WS_URL if testnet else self.WS_URL - - self._session: Optional[aiohttp.ClientSession] = None - self._ws: Optional[aiohttp.ClientWebSocketResponse] = None - - # Rate limiting - self._rate_limit = rate_limit_per_min - self._request_times: List[float] = [] - self._rate_lock = asyncio.Lock() - - async def _get_session(self) -> aiohttp.ClientSession: - """Get or create HTTP session.""" - if self._session is None or self._session.closed: - timeout = ClientTimeout(total=30) - headers = {} - if self.api_key: - headers["X-MBX-APIKEY"] = self.api_key - self._session = aiohttp.ClientSession( - timeout=timeout, - headers=headers - ) - return self._session - - async def close(self): - """Close connections.""" - if self._session and not self._session.closed: - await self._session.close() - if self._ws and not self._ws.closed: - await self._ws.close() - - async def _rate_limit_check(self): - """Ensure we don't exceed rate limits.""" - async with self._rate_lock: - now = time.time() - minute_ago = now - 60 - - # Clean old requests - self._request_times = [t for t in self._request_times if t > minute_ago] - - if len(self._request_times) >= self._rate_limit: - # Wait until oldest request expires - wait_time = self._request_times[0] - minute_ago - if wait_time > 0: - logger.warning(f"Rate limit reached, waiting {wait_time:.2f}s") - await asyncio.sleep(wait_time) - - self._request_times.append(now) - - def _sign_request(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Sign request with API secret.""" - if not self.api_secret: - return params - - params["timestamp"] = int(time.time() * 1000) - query_string = urlencode(params) - signature = hmac.new( - self.api_secret.encode("utf-8"), - query_string.encode("utf-8"), - hashlib.sha256 - ).hexdigest() - params["signature"] = signature - return params - - async def _request( - self, - method: str, - endpoint: str, - params: Optional[Dict[str, Any]] = None, - signed: bool = False - ) -> Any: - """Make HTTP request to Binance API.""" - await self._rate_limit_check() - - session = await self._get_session() - url = f"{self.base_url}{endpoint}" - - if params is None: - params = {} - - if signed: - params = self._sign_request(params) - - try: - async with session.request(method, url, params=params) as response: - data = await response.json() - - if response.status != 200: - error_msg = data.get("msg", "Unknown error") - error_code = data.get("code", -1) - raise BinanceAPIError(error_code, error_msg) - - return data - - except aiohttp.ClientError as e: - logger.error(f"Binance API request failed: {e}") - raise - - # ========================================================================= - # Public Endpoints - # ========================================================================= - - async def get_server_time(self) -> datetime: - """Get Binance server time.""" - data = await self._request("GET", "/api/v3/time") - return datetime.fromtimestamp(data["serverTime"] / 1000) - - async def get_exchange_info(self) -> Dict[str, Any]: - """Get exchange trading rules and symbol info.""" - return await self._request("GET", "/api/v3/exchangeInfo") - - async def get_symbol_info(self, symbol: str) -> Optional[SymbolInfo]: - """Get info for a specific symbol.""" - data = await self.get_exchange_info() - - for s in data.get("symbols", []): - if s["symbol"] == symbol.upper(): - return SymbolInfo( - symbol=s["symbol"], - name=s["symbol"], - asset_type=AssetType.CRYPTO, - base_currency=s["baseAsset"], - quote_currency=s["quoteAsset"], - exchange="binance", - status=SymbolStatus.TRADING if s["status"] == "TRADING" else SymbolStatus.HALTED, - price_precision=s["quotePrecision"], - quantity_precision=s["baseAssetPrecision"], - is_active=s["status"] == "TRADING" - ) - - return None - - async def get_ticker(self, symbol: str) -> Ticker: - """Get 24hr ticker price statistics.""" - data = await self._request( - "GET", - "/api/v3/ticker/24hr", - params={"symbol": symbol.upper()} - ) - - return Ticker( - symbol=data["symbol"], - price=Decimal(data["lastPrice"]), - bid=Decimal(data["bidPrice"]), - ask=Decimal(data["askPrice"]), - volume=Decimal(data["volume"]), - change_24h=Decimal(data["priceChange"]), - change_percent_24h=Decimal(data["priceChangePercent"]), - high_24h=Decimal(data["highPrice"]), - low_24h=Decimal(data["lowPrice"]), - timestamp=datetime.fromtimestamp(data["closeTime"] / 1000) - ) - - async def get_tickers(self, symbols: Optional[List[str]] = None) -> List[Ticker]: - """Get 24hr ticker for multiple symbols.""" - params = {} - if symbols: - params["symbols"] = str(symbols).replace("'", '"') - - data = await self._request("GET", "/api/v3/ticker/24hr", params=params) - - if not isinstance(data, list): - data = [data] - - return [ - Ticker( - symbol=item["symbol"], - price=Decimal(item["lastPrice"]), - bid=Decimal(item["bidPrice"]), - ask=Decimal(item["askPrice"]), - volume=Decimal(item["volume"]), - change_24h=Decimal(item["priceChange"]), - change_percent_24h=Decimal(item["priceChangePercent"]), - high_24h=Decimal(item["highPrice"]), - low_24h=Decimal(item["lowPrice"]), - timestamp=datetime.fromtimestamp(item["closeTime"] / 1000) - ) - for item in data - ] - - async def get_orderbook(self, symbol: str, limit: int = 20) -> OrderBook: - """Get order book for a symbol.""" - data = await self._request( - "GET", - "/api/v3/depth", - params={"symbol": symbol.upper(), "limit": min(limit, 5000)} - ) - - bids = [ - OrderBookLevel(price=Decimal(price), quantity=Decimal(qty)) - for price, qty in data["bids"] - ] - asks = [ - OrderBookLevel(price=Decimal(price), quantity=Decimal(qty)) - for price, qty in data["asks"] - ] - - return OrderBook( - symbol=symbol.upper(), - timestamp=datetime.utcnow(), - bids=bids, - asks=asks - ) - - async def get_trades(self, symbol: str, limit: int = 50) -> List[Trade]: - """Get recent trades for a symbol.""" - data = await self._request( - "GET", - "/api/v3/trades", - params={"symbol": symbol.upper(), "limit": min(limit, 1000)} - ) - - return [ - Trade( - symbol=symbol.upper(), - trade_id=str(item["id"]), - price=Decimal(item["price"]), - quantity=Decimal(item["qty"]), - side="buy" if item["isBuyerMaker"] else "sell", - timestamp=datetime.fromtimestamp(item["time"] / 1000) - ) - for item in data - ] - - async def get_candles( - self, - symbol: str, - timeframe: Timeframe = Timeframe.HOUR_1, - start_time: Optional[datetime] = None, - end_time: Optional[datetime] = None, - limit: int = 100 - ) -> List[OHLCV]: - """Get candlestick/kline data.""" - params = { - "symbol": symbol.upper(), - "interval": TIMEFRAME_MAP[timeframe], - "limit": min(limit, 1000) - } - - if start_time: - params["startTime"] = int(start_time.timestamp() * 1000) - if end_time: - params["endTime"] = int(end_time.timestamp() * 1000) - - data = await self._request("GET", "/api/v3/klines", params=params) - - return [ - OHLCV( - symbol=symbol.upper(), - timeframe=timeframe, - timestamp=datetime.fromtimestamp(item[0] / 1000), - open=Decimal(item[1]), - high=Decimal(item[2]), - low=Decimal(item[3]), - close=Decimal(item[4]), - volume=Decimal(item[5]), - trades=item[8] - ) - for item in data - ] - - # ========================================================================= - # WebSocket Streaming - # ========================================================================= - - async def stream_ticker( - self, - symbol: str, - callback: Callable[[Ticker], None] - ): - """Stream real-time ticker updates for a symbol.""" - stream = f"{symbol.lower()}@ticker" - await self._stream(stream, lambda data: callback(self._parse_ws_ticker(data))) - - async def stream_trades( - self, - symbol: str, - callback: Callable[[Trade], None] - ): - """Stream real-time trades for a symbol.""" - stream = f"{symbol.lower()}@trade" - await self._stream(stream, lambda data: callback(self._parse_ws_trade(data))) - - async def stream_candles( - self, - symbol: str, - timeframe: Timeframe, - callback: Callable[[OHLCV, bool], None] - ): - """Stream real-time candle updates. Callback receives (candle, is_closed).""" - interval = TIMEFRAME_MAP[timeframe] - stream = f"{symbol.lower()}@kline_{interval}" - - def handler(data): - k = data["k"] - candle = OHLCV( - symbol=data["s"], - timeframe=timeframe, - timestamp=datetime.fromtimestamp(k["t"] / 1000), - open=Decimal(k["o"]), - high=Decimal(k["h"]), - low=Decimal(k["l"]), - close=Decimal(k["c"]), - volume=Decimal(k["v"]), - trades=k["n"] - ) - callback(candle, k["x"]) # x = is candle closed - - await self._stream(stream, handler) - - async def stream_orderbook( - self, - symbol: str, - callback: Callable[[OrderBook], None], - depth: int = 20 - ): - """Stream order book updates.""" - stream = f"{symbol.lower()}@depth{depth}@100ms" - - def handler(data): - bids = [ - OrderBookLevel(price=Decimal(p), quantity=Decimal(q)) - for p, q in data.get("bids", []) - ] - asks = [ - OrderBookLevel(price=Decimal(p), quantity=Decimal(q)) - for p, q in data.get("asks", []) - ] - callback(OrderBook( - symbol=symbol.upper(), - timestamp=datetime.utcnow(), - bids=bids, - asks=asks - )) - - await self._stream(stream, handler) - - async def _stream(self, stream: str, handler: Callable): - """Internal WebSocket streaming.""" - url = f"{self.ws_url}/{stream}" - - async with aiohttp.ClientSession() as session: - async with session.ws_connect(url) as ws: - self._ws = ws - logger.info(f"Connected to Binance stream: {stream}") - - async for msg in ws: - if msg.type == aiohttp.WSMsgType.TEXT: - import json - data = json.loads(msg.data) - try: - handler(data) - except Exception as e: - logger.error(f"Stream handler error: {e}") - - elif msg.type == aiohttp.WSMsgType.ERROR: - logger.error(f"WebSocket error: {ws.exception()}") - break - - def _parse_ws_ticker(self, data: Dict) -> Ticker: - """Parse WebSocket ticker message.""" - return Ticker( - symbol=data["s"], - price=Decimal(data["c"]), - bid=Decimal(data["b"]), - ask=Decimal(data["a"]), - volume=Decimal(data["v"]), - change_24h=Decimal(data["p"]), - change_percent_24h=Decimal(data["P"]), - high_24h=Decimal(data["h"]), - low_24h=Decimal(data["l"]), - timestamp=datetime.fromtimestamp(data["E"] / 1000) - ) - - def _parse_ws_trade(self, data: Dict) -> Trade: - """Parse WebSocket trade message.""" - return Trade( - symbol=data["s"], - trade_id=str(data["t"]), - price=Decimal(data["p"]), - quantity=Decimal(data["q"]), - side="buy" if data["m"] else "sell", - timestamp=datetime.fromtimestamp(data["T"] / 1000) - ) - - -class BinanceAPIError(Exception): - """Binance API error.""" - - def __init__(self, code: int, message: str): - self.code = code - self.message = message - super().__init__(f"Binance API Error {code}: {message}") - - -class BinanceDataProvider: - """ - High-level Binance data provider. - - Integrates with the data service for storage and caching. - """ - - def __init__( - self, - client: BinanceClient, - db_pool=None, - cache_ttl: int = 60 - ): - self.client = client - self.db_pool = db_pool - self.cache_ttl = cache_ttl - self._cache: Dict[str, tuple] = {} # key -> (data, timestamp) - - async def get_ticker_cached(self, symbol: str) -> Ticker: - """Get ticker with caching.""" - cache_key = f"ticker:{symbol}" - cached = self._cache.get(cache_key) - - if cached: - data, ts = cached - if time.time() - ts < self.cache_ttl: - return data - - ticker = await self.client.get_ticker(symbol) - self._cache[cache_key] = (ticker, time.time()) - return ticker - - async def sync_candles( - self, - symbol: str, - timeframe: Timeframe, - days: int = 30 - ) -> int: - """Sync historical candles to database.""" - if not self.db_pool: - raise ValueError("Database pool not configured") - - end_time = datetime.utcnow() - start_time = end_time - timedelta(days=days) - - candles = await self.client.get_candles( - symbol=symbol, - timeframe=timeframe, - start_time=start_time, - end_time=end_time, - limit=1000 - ) - - # Insert to database - async with self.db_pool.acquire() as conn: - # Get ticker ID - ticker_id = await conn.fetchval( - "SELECT id FROM market_data.tickers WHERE symbol = $1", - symbol - ) - - if not ticker_id: - # Create ticker - ticker_id = await conn.fetchval( - """ - INSERT INTO market_data.tickers (symbol, asset_type, base_currency, quote_currency) - VALUES ($1, 'crypto', $2, $3) - RETURNING id - """, - symbol, - symbol[:-4] if symbol.endswith("USDT") else symbol[:3], - "USDT" if symbol.endswith("USDT") else "USD" - ) - - # Bulk insert candles - await conn.executemany( - """ - INSERT INTO market_data.ohlcv_1hour - (ticker_id, timestamp, open, high, low, close, volume, trades) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - ON CONFLICT (ticker_id, timestamp) DO UPDATE SET - close = EXCLUDED.close, - high = EXCLUDED.high, - low = EXCLUDED.low, - volume = EXCLUDED.volume - """, - [ - ( - ticker_id, - c.timestamp, - float(c.open), - float(c.high), - float(c.low), - float(c.close), - float(c.volume), - c.trades - ) - for c in candles - ] - ) - - return len(candles) diff --git a/apps/data-service/src/providers/metaapi_client.py b/apps/data-service/src/providers/metaapi_client.py deleted file mode 100644 index 2f908c0..0000000 --- a/apps/data-service/src/providers/metaapi_client.py +++ /dev/null @@ -1,831 +0,0 @@ -""" -MetaAPI.cloud Client for MT4/MT5 Integration -OrbiQuant IA Trading Platform - -Provides real-time data and trading capabilities through MetaAPI.cloud service. -This is the recommended approach for MT4/MT5 integration without requiring -a running terminal. - -Documentation: https://metaapi.cloud/docs/client/ -""" - -import os -import asyncio -import aiohttp -from datetime import datetime, timedelta -from typing import Optional, List, Dict, Any, Callable, AsyncGenerator -from dataclasses import dataclass, field -from enum import Enum -import json -from loguru import logger - - -class OrderType(str, Enum): - """MetaAPI Order Types""" - BUY = "ORDER_TYPE_BUY" - SELL = "ORDER_TYPE_SELL" - BUY_LIMIT = "ORDER_TYPE_BUY_LIMIT" - SELL_LIMIT = "ORDER_TYPE_SELL_LIMIT" - BUY_STOP = "ORDER_TYPE_BUY_STOP" - SELL_STOP = "ORDER_TYPE_SELL_STOP" - - -class PositionType(str, Enum): - """Position types""" - LONG = "POSITION_TYPE_BUY" - SHORT = "POSITION_TYPE_SELL" - - -class AccountState(str, Enum): - """MetaAPI Account States""" - CREATED = "CREATED" - DEPLOYING = "DEPLOYING" - DEPLOYED = "DEPLOYED" - DEPLOY_FAILED = "DEPLOY_FAILED" - UNDEPLOYING = "UNDEPLOYING" - UNDEPLOYED = "UNDEPLOYED" - UNDEPLOY_FAILED = "UNDEPLOY_FAILED" - DELETING = "DELETING" - - -@dataclass -class MT4Tick: - """Real-time tick data""" - symbol: str - timestamp: datetime - bid: float - ask: float - spread: float = field(init=False) - - def __post_init__(self): - self.spread = round(self.ask - self.bid, 5) - - -@dataclass -class MT4Candle: - """OHLCV candle data""" - symbol: str - timeframe: str - time: datetime - open: float - high: float - low: float - close: float - tick_volume: int - spread: Optional[int] = None - real_volume: Optional[int] = None - - -@dataclass -class MT4Position: - """Open position information""" - id: str - symbol: str - type: PositionType - volume: float - open_price: float - current_price: float - swap: float - profit: float - unrealized_profit: float - realized_profit: float - open_time: datetime - stop_loss: Optional[float] = None - take_profit: Optional[float] = None - magic: int = 0 - comment: str = "" - - -@dataclass -class MT4Order: - """Pending order information""" - id: str - symbol: str - type: OrderType - volume: float - open_price: float - current_price: float - open_time: datetime - stop_loss: Optional[float] = None - take_profit: Optional[float] = None - magic: int = 0 - comment: str = "" - state: str = "ORDER_STATE_PLACED" - - -@dataclass -class MT4AccountInfo: - """Account information""" - id: str - name: str - login: str - server: str - platform: str # mt4 or mt5 - type: str # demo or live - currency: str - balance: float - equity: float - margin: float - free_margin: float - leverage: int - margin_level: Optional[float] = None - profit: float = 0.0 - connected: bool = False - - -@dataclass -class TradeResult: - """Result of a trade operation""" - success: bool - order_id: Optional[str] = None - position_id: Optional[str] = None - error_message: Optional[str] = None - error_code: Optional[str] = None - - -class MetaAPIError(Exception): - """MetaAPI specific error""" - def __init__(self, message: str, code: str = None): - self.message = message - self.code = code - super().__init__(message) - - -class MetaAPIClient: - """ - MetaAPI.cloud client for MT4/MT5 trading and data. - - Features: - - Real-time price streaming via WebSocket - - Historical candle data - - Account information and monitoring - - Trade execution (market, pending orders) - - Position management - - Usage: - client = MetaAPIClient(token="your-token", account_id="your-account-id") - await client.connect() - - # Get account info - info = await client.get_account_info() - - # Get real-time price - tick = await client.get_tick("EURUSD") - - # Open a trade - result = await client.open_trade("EURUSD", OrderType.BUY, 0.01, sl=1.0900, tp=1.1100) - """ - - # MetaAPI endpoints - PROVISIONING_API = "https://mt-provisioning-api-v1.agiliumtrade.agiliumtrade.ai" - CLIENT_API = "https://mt-client-api-v1.agiliumtrade.agiliumtrade.ai" - - def __init__( - self, - token: Optional[str] = None, - account_id: Optional[str] = None, - application: str = "OrbiQuant" - ): - """ - Initialize MetaAPI client. - - Args: - token: MetaAPI access token (or from METAAPI_TOKEN env) - account_id: MetaAPI account ID (or from METAAPI_ACCOUNT_ID env) - application: Application name for tracking - """ - self.token = token or os.getenv("METAAPI_TOKEN") - self.account_id = account_id or os.getenv("METAAPI_ACCOUNT_ID") - self.application = application - - if not self.token: - raise ValueError("MetaAPI token is required. Set METAAPI_TOKEN env or pass token parameter.") - - self._session: Optional[aiohttp.ClientSession] = None - self._ws: Optional[aiohttp.ClientWebSocketResponse] = None - self._connected = False - self._account_info: Optional[MT4AccountInfo] = None - - # Callbacks for real-time events - self._tick_callbacks: Dict[str, List[Callable]] = {} - self._position_callbacks: List[Callable] = [] - - # Cache - self._symbols_cache: Dict[str, Dict] = {} - self._cache_ttl = 300 # 5 minutes - self._cache_time: Dict[str, datetime] = {} - - @property - def is_connected(self) -> bool: - return self._connected - - @property - def account_info(self) -> Optional[MT4AccountInfo]: - return self._account_info - - async def _get_session(self) -> aiohttp.ClientSession: - """Get or create HTTP session""" - if self._session is None or self._session.closed: - self._session = aiohttp.ClientSession( - headers={ - "auth-token": self.token, - "Content-Type": "application/json" - }, - timeout=aiohttp.ClientTimeout(total=30) - ) - return self._session - - async def _request( - self, - method: str, - url: str, - json_data: Optional[Dict] = None, - params: Optional[Dict] = None - ) -> Dict[str, Any]: - """Make HTTP request to MetaAPI""" - session = await self._get_session() - - try: - async with session.request(method, url, json=json_data, params=params) as resp: - if resp.status == 200: - return await resp.json() - elif resp.status == 202: - # Accepted - async operation started - return {"status": "accepted"} - else: - error_text = await resp.text() - try: - error_data = json.loads(error_text) - raise MetaAPIError( - error_data.get("message", error_text), - error_data.get("id") - ) - except json.JSONDecodeError: - raise MetaAPIError(error_text) - - except aiohttp.ClientError as e: - raise MetaAPIError(f"HTTP error: {str(e)}") - - # ========================================== - # Connection Management - # ========================================== - - async def connect(self) -> bool: - """ - Connect to MetaAPI and deploy account if needed. - - Returns: - True if connected successfully - """ - if not self.account_id: - raise ValueError("Account ID is required to connect") - - logger.info(f"Connecting to MetaAPI account {self.account_id}...") - - try: - # Get account state - account = await self._request( - "GET", - f"{self.PROVISIONING_API}/users/current/accounts/{self.account_id}" - ) - - state = account.get("state", "CREATED") - - # Deploy if not deployed - if state not in ["DEPLOYED", "DEPLOYING"]: - logger.info(f"Account state is {state}, deploying...") - await self._request( - "POST", - f"{self.PROVISIONING_API}/users/current/accounts/{self.account_id}/deploy" - ) - - # Wait for deployment - for _ in range(60): # Max 60 seconds - await asyncio.sleep(1) - account = await self._request( - "GET", - f"{self.PROVISIONING_API}/users/current/accounts/{self.account_id}" - ) - state = account.get("state") - if state == "DEPLOYED": - break - elif state == "DEPLOY_FAILED": - raise MetaAPIError("Account deployment failed") - - # Wait for connection to broker - logger.info("Waiting for broker connection...") - for _ in range(30): - info = await self._request( - "GET", - f"{self.CLIENT_API}/users/current/accounts/{self.account_id}/account-information" - ) - if info.get("connected", False): - break - await asyncio.sleep(1) - - # Store account info - self._account_info = MT4AccountInfo( - id=self.account_id, - name=account.get("name", ""), - login=str(account.get("login", "")), - server=account.get("server", ""), - platform=account.get("platform", "mt4"), - type=account.get("type", "demo"), - currency=info.get("currency", "USD"), - balance=info.get("balance", 0), - equity=info.get("equity", 0), - margin=info.get("margin", 0), - free_margin=info.get("freeMargin", 0), - leverage=info.get("leverage", 100), - margin_level=info.get("marginLevel"), - profit=info.get("profit", 0), - connected=info.get("connected", False) - ) - - self._connected = True - logger.info(f"Connected to MT4 account {self._account_info.login} on {self._account_info.server}") - logger.info(f"Balance: {self._account_info.balance} {self._account_info.currency}") - - return True - - except Exception as e: - logger.error(f"Failed to connect to MetaAPI: {e}") - self._connected = False - raise - - async def disconnect(self): - """Disconnect from MetaAPI""" - if self._ws: - await self._ws.close() - self._ws = None - - if self._session: - await self._session.close() - self._session = None - - self._connected = False - logger.info("Disconnected from MetaAPI") - - # ========================================== - # Account Information - # ========================================== - - async def get_account_info(self) -> MT4AccountInfo: - """Get current account information""" - if not self._connected: - raise MetaAPIError("Not connected") - - info = await self._request( - "GET", - f"{self.CLIENT_API}/users/current/accounts/{self.account_id}/account-information" - ) - - self._account_info = MT4AccountInfo( - id=self.account_id, - name=self._account_info.name if self._account_info else "", - login=self._account_info.login if self._account_info else "", - server=self._account_info.server if self._account_info else "", - platform=self._account_info.platform if self._account_info else "mt4", - type=self._account_info.type if self._account_info else "demo", - currency=info.get("currency", "USD"), - balance=info.get("balance", 0), - equity=info.get("equity", 0), - margin=info.get("margin", 0), - free_margin=info.get("freeMargin", 0), - leverage=info.get("leverage", 100), - margin_level=info.get("marginLevel"), - profit=info.get("profit", 0), - connected=info.get("connected", False) - ) - - return self._account_info - - # ========================================== - # Market Data - # ========================================== - - async def get_tick(self, symbol: str) -> MT4Tick: - """ - Get current tick (bid/ask) for a symbol. - - Args: - symbol: Trading symbol (e.g., "EURUSD", "XAUUSD") - - Returns: - MT4Tick with current prices - """ - if not self._connected: - raise MetaAPIError("Not connected") - - data = await self._request( - "GET", - f"{self.CLIENT_API}/users/current/accounts/{self.account_id}/symbols/{symbol}/current-price" - ) - - return MT4Tick( - symbol=symbol, - timestamp=datetime.fromisoformat(data["time"].replace("Z", "+00:00")), - bid=data["bid"], - ask=data["ask"] - ) - - async def get_candles( - self, - symbol: str, - timeframe: str = "1h", - start_time: Optional[datetime] = None, - limit: int = 1000 - ) -> List[MT4Candle]: - """ - Get historical candles. - - Args: - symbol: Trading symbol - timeframe: Candle timeframe (1m, 5m, 15m, 30m, 1h, 4h, 1d, 1w, 1mn) - start_time: Start time (default: limit candles back from now) - limit: Maximum candles to fetch (max 1000) - - Returns: - List of MT4Candle objects - """ - if not self._connected: - raise MetaAPIError("Not connected") - - params = {"limit": min(limit, 1000)} - if start_time: - params["startTime"] = start_time.isoformat() - - data = await self._request( - "GET", - f"{self.CLIENT_API}/users/current/accounts/{self.account_id}/historical-market-data/symbols/{symbol}/timeframes/{timeframe}/candles", - params=params - ) - - candles = [] - for c in data: - candles.append(MT4Candle( - symbol=symbol, - timeframe=timeframe, - time=datetime.fromisoformat(c["time"].replace("Z", "+00:00")), - open=c["open"], - high=c["high"], - low=c["low"], - close=c["close"], - tick_volume=c.get("tickVolume", 0), - spread=c.get("spread"), - real_volume=c.get("volume") - )) - - return candles - - async def get_symbols(self) -> List[Dict]: - """Get list of available symbols""" - if not self._connected: - raise MetaAPIError("Not connected") - - data = await self._request( - "GET", - f"{self.CLIENT_API}/users/current/accounts/{self.account_id}/symbols" - ) - - return data - - async def get_symbol_specification(self, symbol: str) -> Dict: - """Get symbol specification (contract size, digits, etc.)""" - if not self._connected: - raise MetaAPIError("Not connected") - - # Check cache - if symbol in self._symbols_cache: - cache_time = self._cache_time.get(symbol) - if cache_time and (datetime.now() - cache_time).seconds < self._cache_ttl: - return self._symbols_cache[symbol] - - data = await self._request( - "GET", - f"{self.CLIENT_API}/users/current/accounts/{self.account_id}/symbols/{symbol}/specification" - ) - - self._symbols_cache[symbol] = data - self._cache_time[symbol] = datetime.now() - - return data - - # ========================================== - # Position Management - # ========================================== - - async def get_positions(self) -> List[MT4Position]: - """Get all open positions""" - if not self._connected: - raise MetaAPIError("Not connected") - - data = await self._request( - "GET", - f"{self.CLIENT_API}/users/current/accounts/{self.account_id}/positions" - ) - - positions = [] - for p in data: - positions.append(MT4Position( - id=p["id"], - symbol=p["symbol"], - type=PositionType(p["type"]), - volume=p["volume"], - open_price=p["openPrice"], - current_price=p.get("currentPrice", p["openPrice"]), - swap=p.get("swap", 0), - profit=p.get("profit", 0), - unrealized_profit=p.get("unrealizedProfit", 0), - realized_profit=p.get("realizedProfit", 0), - open_time=datetime.fromisoformat(p["time"].replace("Z", "+00:00")), - stop_loss=p.get("stopLoss"), - take_profit=p.get("takeProfit"), - magic=p.get("magic", 0), - comment=p.get("comment", "") - )) - - return positions - - async def get_orders(self) -> List[MT4Order]: - """Get all pending orders""" - if not self._connected: - raise MetaAPIError("Not connected") - - data = await self._request( - "GET", - f"{self.CLIENT_API}/users/current/accounts/{self.account_id}/orders" - ) - - orders = [] - for o in data: - orders.append(MT4Order( - id=o["id"], - symbol=o["symbol"], - type=OrderType(o["type"]), - volume=o["volume"], - open_price=o["openPrice"], - current_price=o.get("currentPrice", o["openPrice"]), - open_time=datetime.fromisoformat(o["time"].replace("Z", "+00:00")), - stop_loss=o.get("stopLoss"), - take_profit=o.get("takeProfit"), - magic=o.get("magic", 0), - comment=o.get("comment", ""), - state=o.get("state", "ORDER_STATE_PLACED") - )) - - return orders - - async def get_history( - self, - start_time: datetime, - end_time: Optional[datetime] = None, - limit: int = 1000 - ) -> List[Dict]: - """Get trade history""" - if not self._connected: - raise MetaAPIError("Not connected") - - params = { - "startTime": start_time.isoformat(), - "limit": limit - } - if end_time: - params["endTime"] = end_time.isoformat() - - data = await self._request( - "GET", - f"{self.CLIENT_API}/users/current/accounts/{self.account_id}/history-deals", - params=params - ) - - return data - - # ========================================== - # Trading Operations - # ========================================== - - async def open_trade( - self, - symbol: str, - order_type: OrderType, - volume: float, - price: Optional[float] = None, - sl: Optional[float] = None, - tp: Optional[float] = None, - comment: str = "OrbiQuant", - magic: int = 12345 - ) -> TradeResult: - """ - Open a new trade. - - Args: - symbol: Trading symbol - order_type: BUY or SELL (or pending order types) - volume: Trade volume in lots - price: Price for pending orders (None for market orders) - sl: Stop loss price - tp: Take profit price - comment: Order comment - magic: Magic number for identification - - Returns: - TradeResult with order details or error - """ - if not self._connected: - raise MetaAPIError("Not connected") - - payload = { - "symbol": symbol, - "actionType": order_type.value, - "volume": volume, - "comment": comment, - "magic": magic - } - - if price is not None: - payload["openPrice"] = price - if sl is not None: - payload["stopLoss"] = sl - if tp is not None: - payload["takeProfit"] = tp - - try: - data = await self._request( - "POST", - f"{self.CLIENT_API}/users/current/accounts/{self.account_id}/trade", - json_data=payload - ) - - return TradeResult( - success=True, - order_id=data.get("orderId"), - position_id=data.get("positionId") - ) - - except MetaAPIError as e: - return TradeResult( - success=False, - error_message=e.message, - error_code=e.code - ) - - async def close_position( - self, - position_id: str, - volume: Optional[float] = None - ) -> TradeResult: - """ - Close a position. - - Args: - position_id: Position ID to close - volume: Volume to close (None = close all) - - Returns: - TradeResult - """ - if not self._connected: - raise MetaAPIError("Not connected") - - payload = { - "actionType": "POSITION_CLOSE_ID", - "positionId": position_id - } - - if volume is not None: - payload["volume"] = volume - - try: - data = await self._request( - "POST", - f"{self.CLIENT_API}/users/current/accounts/{self.account_id}/trade", - json_data=payload - ) - - return TradeResult(success=True, position_id=position_id) - - except MetaAPIError as e: - return TradeResult( - success=False, - error_message=e.message, - error_code=e.code - ) - - async def modify_position( - self, - position_id: str, - sl: Optional[float] = None, - tp: Optional[float] = None - ) -> TradeResult: - """ - Modify position SL/TP. - - Args: - position_id: Position ID - sl: New stop loss (None = unchanged) - tp: New take profit (None = unchanged) - - Returns: - TradeResult - """ - if not self._connected: - raise MetaAPIError("Not connected") - - payload = { - "actionType": "POSITION_MODIFY", - "positionId": position_id - } - - if sl is not None: - payload["stopLoss"] = sl - if tp is not None: - payload["takeProfit"] = tp - - try: - await self._request( - "POST", - f"{self.CLIENT_API}/users/current/accounts/{self.account_id}/trade", - json_data=payload - ) - - return TradeResult(success=True, position_id=position_id) - - except MetaAPIError as e: - return TradeResult( - success=False, - error_message=e.message, - error_code=e.code - ) - - async def cancel_order(self, order_id: str) -> TradeResult: - """Cancel a pending order""" - if not self._connected: - raise MetaAPIError("Not connected") - - payload = { - "actionType": "ORDER_CANCEL", - "orderId": order_id - } - - try: - await self._request( - "POST", - f"{self.CLIENT_API}/users/current/accounts/{self.account_id}/trade", - json_data=payload - ) - - return TradeResult(success=True, order_id=order_id) - - except MetaAPIError as e: - return TradeResult( - success=False, - error_message=e.message, - error_code=e.code - ) - - # ========================================== - # Utility Methods - # ========================================== - - async def calculate_margin( - self, - symbol: str, - order_type: OrderType, - volume: float, - price: Optional[float] = None - ) -> Dict[str, float]: - """Calculate required margin for a trade""" - if not self._connected: - raise MetaAPIError("Not connected") - - payload = { - "symbol": symbol, - "actionType": order_type.value, - "volume": volume - } - - if price: - payload["openPrice"] = price - - data = await self._request( - "POST", - f"{self.CLIENT_API}/users/current/accounts/{self.account_id}/calculate-margin", - json_data=payload - ) - - return { - "margin": data.get("margin", 0), - "free_margin_after": self._account_info.free_margin - data.get("margin", 0) if self._account_info else 0 - } - - -# Convenience function -async def create_metaapi_client( - token: Optional[str] = None, - account_id: Optional[str] = None -) -> MetaAPIClient: - """Create and connect a MetaAPI client""" - client = MetaAPIClient(token=token, account_id=account_id) - await client.connect() - return client diff --git a/apps/data-service/src/providers/mt4_client.py b/apps/data-service/src/providers/mt4_client.py deleted file mode 100644 index 26615bb..0000000 --- a/apps/data-service/src/providers/mt4_client.py +++ /dev/null @@ -1,632 +0,0 @@ -""" -MetaTrader 4 Direct Server Connection Client -OrbiQuant IA Trading Platform - -Provides direct connection to MT4 server without requiring MT4 terminal. -Uses the MT4 Manager API protocol or alternative open protocols. - -Options for MT4 connection: -1. dwx-zeromq-connector: Uses ZeroMQ bridge with MT4 EA -2. mt4-server-api: Direct TCP connection using reverse-engineered protocol -3. metaapi.cloud: Cloud service for MT4/MT5 API access -4. ctrader-fix: FIX protocol for cTrader (alternative) -""" - -import asyncio -import struct -import socket -import hashlib -from datetime import datetime, timedelta -from typing import Optional, List, Dict, Any, Callable -from dataclasses import dataclass, field -from enum import Enum, IntEnum -import logging - -logger = logging.getLogger(__name__) - - -class OrderType(IntEnum): - """MT4 Order Types""" - OP_BUY = 0 - OP_SELL = 1 - OP_BUYLIMIT = 2 - OP_SELLLIMIT = 3 - OP_BUYSTOP = 4 - OP_SELLSTOP = 5 - - -class TradeOperation(IntEnum): - """MT4 Trade Operations""" - OPEN = 1 - CLOSE = 2 - MODIFY = 3 - DELETE = 4 - - -@dataclass -class MT4Tick: - """Real-time tick data.""" - symbol: str - timestamp: datetime - bid: float - ask: float - spread: float = field(init=False) - - def __post_init__(self): - self.spread = self.ask - self.bid - - -@dataclass -class MT4Order: - """MT4 Order information.""" - ticket: int - symbol: str - order_type: OrderType - lots: float - open_price: float - sl: float - tp: float - open_time: datetime - close_price: Optional[float] = None - close_time: Optional[datetime] = None - profit: float = 0.0 - swap: float = 0.0 - commission: float = 0.0 - magic: int = 0 - comment: str = "" - - -@dataclass -class MT4AccountInfo: - """MT4 Account information.""" - login: int - name: str - server: str - currency: str - leverage: int - balance: float - equity: float - margin: float - free_margin: float - margin_level: float - profit: float - - -@dataclass -class MT4Symbol: - """MT4 Symbol specification.""" - symbol: str - digits: int - point: float - spread: int - stops_level: int - contract_size: float - tick_value: float - tick_size: float - min_lot: float - max_lot: float - lot_step: float - swap_long: float - swap_short: float - - -class MT4ConnectionError(Exception): - """MT4 Connection error.""" - pass - - -class MT4TradeError(Exception): - """MT4 Trade execution error.""" - pass - - -class MT4Client: - """ - MetaTrader 4 Client using direct server connection. - - This implementation uses a hybrid approach: - 1. For real-time data: WebSocket/TCP connection to a bridge service - 2. For trading: REST API through MetaAPI or similar service - - For full direct connection without any bridge, you would need: - - Reverse-engineered MT4 protocol (complex, may violate ToS) - - Or: MT4 Manager API license from MetaQuotes (expensive) - """ - - def __init__( - self, - server: str, - login: int, - password: str, - investor_mode: bool = False, - timeout: int = 30 - ): - self.server = server - self.login = login - self.password = password - self.investor_mode = investor_mode - self.timeout = timeout - - self._connected = False - self._socket: Optional[socket.socket] = None - self._account_info: Optional[MT4AccountInfo] = None - self._symbols: Dict[str, MT4Symbol] = {} - - # Callbacks for real-time events - self._tick_callbacks: List[Callable[[MT4Tick], None]] = [] - self._order_callbacks: List[Callable[[MT4Order], None]] = [] - - @property - def is_connected(self) -> bool: - return self._connected - - async def connect(self) -> bool: - """ - Connect to MT4 server. - - Note: Direct MT4 server connection requires proprietary protocol. - This implementation assumes a bridge service or MetaAPI. - """ - try: - # Parse server address - if ":" in self.server: - host, port = self.server.rsplit(":", 1) - port = int(port) - else: - host = self.server - port = 443 - - logger.info(f"Connecting to MT4 server {host}:{port}") - - # For direct connection, we would establish TCP socket here - # However, MT4 uses proprietary encryption - # Instead, we'll use a bridge pattern - - self._connected = True - logger.info(f"Connected to MT4 as {self.login}") - - # Load account info - await self._load_account_info() - - return True - - except Exception as e: - logger.error(f"Failed to connect to MT4: {e}") - self._connected = False - raise MT4ConnectionError(f"Connection failed: {e}") - - async def disconnect(self): - """Disconnect from MT4 server.""" - if self._socket: - self._socket.close() - self._socket = None - self._connected = False - logger.info("Disconnected from MT4") - - async def _load_account_info(self): - """Load account information.""" - # In real implementation, this would query the server - # Placeholder for now - pass - - async def get_account_info(self) -> MT4AccountInfo: - """Get current account information.""" - if not self._connected: - raise MT4ConnectionError("Not connected") - - # Would query server for live data - return self._account_info - - async def get_symbol_info(self, symbol: str) -> Optional[MT4Symbol]: - """Get symbol specification.""" - if symbol in self._symbols: - return self._symbols[symbol] - - # Query server for symbol info - # Placeholder - would parse server response - return None - - async def get_tick(self, symbol: str) -> Optional[MT4Tick]: - """Get current tick for symbol.""" - if not self._connected: - raise MT4ConnectionError("Not connected") - - # Query current prices - # In real implementation, this uses the MarketInfo command - return None - - async def subscribe_ticks( - self, - symbols: List[str], - callback: Callable[[MT4Tick], None] - ): - """Subscribe to real-time tick updates.""" - self._tick_callbacks.append(callback) - - # In real implementation, send subscription request to server - logger.info(f"Subscribed to ticks for {symbols}") - - async def get_spread(self, symbol: str) -> float: - """Get current spread for symbol in points.""" - tick = await self.get_tick(symbol) - if tick: - symbol_info = await self.get_symbol_info(symbol) - if symbol_info: - return (tick.ask - tick.bid) / symbol_info.point - return 0.0 - - async def get_spread_in_price(self, symbol: str) -> float: - """Get current spread as price difference.""" - tick = await self.get_tick(symbol) - return tick.spread if tick else 0.0 - - # ========================================== - # Trading Operations - # ========================================== - - async def open_order( - self, - symbol: str, - order_type: OrderType, - lots: float, - price: float = 0, - sl: float = 0, - tp: float = 0, - slippage: int = 3, - magic: int = 0, - comment: str = "" - ) -> Optional[int]: - """ - Open a new order. - - Args: - symbol: Trading symbol - order_type: Type of order (buy, sell, etc.) - lots: Trade volume - price: Order price (0 for market orders) - sl: Stop loss price - tp: Take profit price - slippage: Maximum slippage in points - magic: Expert Advisor magic number - comment: Order comment - - Returns: - Order ticket number or None if failed - """ - if not self._connected: - raise MT4ConnectionError("Not connected") - - if self.investor_mode: - raise MT4TradeError("Cannot trade in investor mode") - - logger.info(f"Opening {order_type.name} order for {symbol}, {lots} lots") - - # Build trade request - # In real implementation, this sends TradeTransaction command - - # Placeholder return - return None - - async def close_order( - self, - ticket: int, - lots: Optional[float] = None, - price: float = 0, - slippage: int = 3 - ) -> bool: - """ - Close an existing order. - - Args: - ticket: Order ticket number - lots: Volume to close (None = close all) - price: Close price (0 for market) - slippage: Maximum slippage - - Returns: - True if successful - """ - if not self._connected: - raise MT4ConnectionError("Not connected") - - logger.info(f"Closing order {ticket}") - - # Build close request - # In real implementation, sends TradeTransaction close command - - return False - - async def modify_order( - self, - ticket: int, - price: Optional[float] = None, - sl: Optional[float] = None, - tp: Optional[float] = None - ) -> bool: - """ - Modify an existing order. - - Args: - ticket: Order ticket - price: New order price (for pending orders) - sl: New stop loss - tp: New take profit - - Returns: - True if successful - """ - if not self._connected: - raise MT4ConnectionError("Not connected") - - logger.info(f"Modifying order {ticket}") - - return False - - async def get_orders(self, symbol: Optional[str] = None) -> List[MT4Order]: - """Get all open orders.""" - if not self._connected: - raise MT4ConnectionError("Not connected") - - # Query open orders from server - return [] - - async def get_history( - self, - start_time: datetime, - end_time: datetime, - symbol: Optional[str] = None - ) -> List[MT4Order]: - """Get order history.""" - if not self._connected: - raise MT4ConnectionError("Not connected") - - # Query history from server - return [] - - -class MetaAPIClient(MT4Client): - """ - MT4/MT5 Client using MetaAPI.cloud service. - - MetaAPI provides REST/WebSocket API for MT4/MT5 without requiring - the terminal or proprietary protocols. - - Requires MetaAPI account and token. - """ - - METAAPI_URL = "https://mt-client-api-v1.agiliumtrade.agiliumtrade.ai" - - def __init__( - self, - token: str, - account_id: str, - server: str = "", - login: int = 0, - password: str = "", - **kwargs - ): - super().__init__(server, login, password, **kwargs) - self.token = token - self.account_id = account_id - self._session = None - - async def connect(self) -> bool: - """Connect via MetaAPI.""" - import aiohttp - - self._session = aiohttp.ClientSession( - headers={"auth-token": self.token} - ) - - try: - # Deploy account if needed - async with self._session.get( - f"{self.METAAPI_URL}/users/current/accounts/{self.account_id}" - ) as resp: - if resp.status == 200: - data = await resp.json() - self._connected = True - logger.info(f"Connected to MetaAPI account {self.account_id}") - return True - else: - raise MT4ConnectionError(f"MetaAPI error: {resp.status}") - - except Exception as e: - logger.error(f"MetaAPI connection failed: {e}") - raise MT4ConnectionError(str(e)) - - async def disconnect(self): - """Disconnect from MetaAPI.""" - if self._session: - await self._session.close() - self._connected = False - - async def get_tick(self, symbol: str) -> Optional[MT4Tick]: - """Get current tick via MetaAPI.""" - if not self._session: - return None - - try: - async with self._session.get( - f"{self.METAAPI_URL}/users/current/accounts/{self.account_id}/symbols/{symbol}/current-price" - ) as resp: - if resp.status == 200: - data = await resp.json() - return MT4Tick( - symbol=symbol, - timestamp=datetime.fromisoformat(data["time"].replace("Z", "+00:00")), - bid=data["bid"], - ask=data["ask"] - ) - except Exception as e: - logger.error(f"Error getting tick: {e}") - - return None - - async def open_order( - self, - symbol: str, - order_type: OrderType, - lots: float, - price: float = 0, - sl: float = 0, - tp: float = 0, - slippage: int = 3, - magic: int = 0, - comment: str = "" - ) -> Optional[int]: - """Open order via MetaAPI.""" - if not self._session: - raise MT4ConnectionError("Not connected") - - action_type = "ORDER_TYPE_BUY" if order_type == OrderType.OP_BUY else "ORDER_TYPE_SELL" - - payload = { - "symbol": symbol, - "actionType": action_type, - "volume": lots, - "stopLoss": sl if sl > 0 else None, - "takeProfit": tp if tp > 0 else None, - "comment": comment - } - - try: - async with self._session.post( - f"{self.METAAPI_URL}/users/current/accounts/{self.account_id}/trade", - json=payload - ) as resp: - if resp.status == 200: - data = await resp.json() - return data.get("orderId") - else: - error = await resp.text() - raise MT4TradeError(f"Trade failed: {error}") - - except Exception as e: - logger.error(f"Order execution error: {e}") - raise MT4TradeError(str(e)) - - -class SpreadTracker: - """ - Tracks and analyzes spreads for trading cost calculations. - """ - - def __init__(self, db_pool): - self.db = db_pool - self._spread_cache: Dict[str, Dict[str, float]] = {} - - async def record_spread( - self, - account_id: int, - ticker_id: int, - bid: float, - ask: float, - timestamp: datetime - ): - """Record a spread observation.""" - spread_points = ask - bid - spread_pct = (spread_points / ((bid + ask) / 2)) * 100 if bid > 0 else 0 - - async with self.db.acquire() as conn: - await conn.execute( - """ - INSERT INTO broker_integration.broker_prices - (account_id, ticker_id, timestamp, bid, ask, spread_points, spread_pct) - VALUES ($1, $2, $3, $4, $5, $6, $7) - ON CONFLICT (account_id, ticker_id, timestamp) DO NOTHING - """, - account_id, ticker_id, timestamp, bid, ask, spread_points, spread_pct - ) - - async def calculate_spread_statistics( - self, - account_id: int, - ticker_id: int, - period_hours: int = 24 - ) -> Dict[str, float]: - """Calculate spread statistics for a period.""" - async with self.db.acquire() as conn: - row = await conn.fetchrow( - """ - SELECT - AVG(spread_points) as avg_spread, - MIN(spread_points) as min_spread, - MAX(spread_points) as max_spread, - STDDEV(spread_points) as std_spread, - PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY spread_points) as median_spread, - PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY spread_points) as p95_spread, - COUNT(*) as sample_count - FROM broker_integration.broker_prices - WHERE account_id = $1 - AND ticker_id = $2 - AND timestamp > NOW() - INTERVAL '%s hours' - """ % period_hours, - account_id, ticker_id - ) - - return dict(row) if row else {} - - async def get_session_spread( - self, - ticker_id: int, - session: str = "london" - ) -> float: - """Get average spread for a trading session.""" - async with self.db.acquire() as conn: - row = await conn.fetchrow( - """ - SELECT avg_spread - FROM broker_integration.spread_statistics - WHERE ticker_id = $1 AND session_type = $2 - ORDER BY period_start DESC - LIMIT 1 - """, - ticker_id, session - ) - - return row["avg_spread"] if row else 0.0 - - def calculate_spread_adjusted_rr( - self, - entry_price: float, - stop_loss: float, - take_profit: float, - spread: float, - is_long: bool - ) -> Dict[str, float]: - """ - Calculate spread-adjusted risk/reward ratio. - - For LONG trades: - - Entry is at ASK (higher), exit at BID (lower) - - Effective entry = entry_price + spread/2 - - Risk increases, reward decreases - - For SHORT trades: - - Entry is at BID (lower), exit at ASK (higher) - - Same adjustment applies - """ - if is_long: - effective_entry = entry_price + spread / 2 - risk = effective_entry - stop_loss - reward = take_profit - effective_entry - else: - effective_entry = entry_price - spread / 2 - risk = stop_loss - effective_entry - reward = effective_entry - take_profit - - gross_rr = abs(take_profit - entry_price) / abs(entry_price - stop_loss) - net_rr = reward / risk if risk > 0 else 0 - - spread_cost_pct = (spread / entry_price) * 100 - - return { - "gross_rr": gross_rr, - "net_rr": net_rr, - "effective_entry": effective_entry, - "adjusted_risk": risk, - "adjusted_reward": reward, - "spread_cost_pct": spread_cost_pct, - "rr_reduction": gross_rr - net_rr - } diff --git a/apps/data-service/src/providers/polygon_client.py b/apps/data-service/src/providers/polygon_client.py deleted file mode 100644 index 8ae4917..0000000 --- a/apps/data-service/src/providers/polygon_client.py +++ /dev/null @@ -1,479 +0,0 @@ -""" -Polygon.io / Massive.com API Client -OrbiQuant IA Trading Platform - -Provides access to market data from Polygon/Massive API. -Supports: Forex (C:), Crypto (X:), Indices (I:), Futures -""" - -import os -import asyncio -import aiohttp -from datetime import datetime, timedelta -from typing import Optional, List, Dict, Any, AsyncGenerator -from dataclasses import dataclass -from enum import Enum -import logging - -logger = logging.getLogger(__name__) - - -class AssetType(Enum): - FOREX = "forex" - CRYPTO = "crypto" - INDEX = "index" - FUTURES = "futures" - STOCK = "stock" - - -class Timeframe(Enum): - MINUTE_1 = ("1", "minute") - MINUTE_5 = ("5", "minute") - MINUTE_15 = ("15", "minute") - HOUR_1 = ("1", "hour") - HOUR_4 = ("4", "hour") - DAY_1 = ("1", "day") - - -@dataclass -class OHLCVBar: - """OHLCV bar data.""" - timestamp: datetime - open: float - high: float - low: float - close: float - volume: float - vwap: Optional[float] = None - transactions: Optional[int] = None - - -@dataclass -class TickerSnapshot: - """Current ticker snapshot.""" - symbol: str - bid: float - ask: float - last_price: float - timestamp: datetime - daily_open: Optional[float] = None - daily_high: Optional[float] = None - daily_low: Optional[float] = None - daily_close: Optional[float] = None - daily_volume: Optional[float] = None - - -class PolygonClient: - """ - Async client for Polygon.io / Massive.com API. - - Supports: - - Historical OHLCV data (aggregates) - - Real-time snapshots - - Ticker reference data - """ - - BASE_URL = "https://api.polygon.io" - - # Symbol prefixes by asset type - PREFIXES = { - AssetType.FOREX: "C:", - AssetType.CRYPTO: "X:", - AssetType.INDEX: "I:", - AssetType.FUTURES: "", # No prefix, uses contract symbol - AssetType.STOCK: "", - } - - def __init__( - self, - api_key: Optional[str] = None, - rate_limit_per_min: int = 5, - base_url: Optional[str] = None - ): - self.api_key = api_key or os.getenv("POLYGON_API_KEY") - if not self.api_key: - raise ValueError("POLYGON_API_KEY is required") - - self.base_url = base_url or self.BASE_URL - self.rate_limit = rate_limit_per_min - self._last_request_time = datetime.min - self._request_count = 0 - self._session: Optional[aiohttp.ClientSession] = None - - async def __aenter__(self): - self._session = aiohttp.ClientSession() - return self - - async def __aexit__(self, exc_type, exc_val, exc_tb): - if self._session: - await self._session.close() - - async def _rate_limit_wait(self): - """Implement rate limiting.""" - now = datetime.now() - - # Reset counter if minute has passed - if (now - self._last_request_time).total_seconds() >= 60: - self._request_count = 0 - self._last_request_time = now - - # Wait if rate limit reached - if self._request_count >= self.rate_limit: - wait_time = 60 - (now - self._last_request_time).total_seconds() - if wait_time > 0: - logger.debug(f"Rate limit reached, waiting {wait_time:.1f}s") - await asyncio.sleep(wait_time) - self._request_count = 0 - self._last_request_time = datetime.now() - - self._request_count += 1 - - async def _request( - self, - endpoint: str, - params: Optional[Dict[str, Any]] = None - ) -> Dict[str, Any]: - """Make API request with rate limiting.""" - await self._rate_limit_wait() - - params = params or {} - params["apiKey"] = self.api_key - - url = f"{self.base_url}{endpoint}" - - if not self._session: - self._session = aiohttp.ClientSession() - - async with self._session.get(url, params=params) as response: - if response.status == 429: - # Rate limited, wait and retry - retry_after = int(response.headers.get("Retry-After", 60)) - logger.warning(f"Rate limited, waiting {retry_after}s") - await asyncio.sleep(retry_after) - return await self._request(endpoint, params) - - response.raise_for_status() - return await response.json() - - def _format_symbol(self, symbol: str, asset_type: AssetType) -> str: - """Format symbol with appropriate prefix.""" - prefix = self.PREFIXES.get(asset_type, "") - if symbol.startswith(prefix): - return symbol - return f"{prefix}{symbol}" - - async def get_aggregates( - self, - symbol: str, - asset_type: AssetType, - timeframe: Timeframe, - start_date: datetime, - end_date: datetime, - adjusted: bool = True, - limit: int = 50000 - ) -> AsyncGenerator[OHLCVBar, None]: - """ - Get historical OHLCV data (aggregates). - - Args: - symbol: Ticker symbol (e.g., 'EURUSD', 'BTCUSD', 'SPX') - asset_type: Type of asset - timeframe: Bar timeframe - start_date: Start date - end_date: End date - adjusted: Whether to adjust for splits - limit: Max results per request - - Yields: - OHLCVBar objects - """ - formatted_symbol = self._format_symbol(symbol, asset_type) - multiplier, timespan = timeframe.value - - start_str = start_date.strftime("%Y-%m-%d") - end_str = end_date.strftime("%Y-%m-%d") - - endpoint = f"/v2/aggs/ticker/{formatted_symbol}/range/{multiplier}/{timespan}/{start_str}/{end_str}" - - params = { - "adjusted": str(adjusted).lower(), - "sort": "asc", - "limit": limit - } - - while True: - data = await self._request(endpoint, params) - - results = data.get("results", []) - if not results: - break - - for bar in results: - yield OHLCVBar( - timestamp=datetime.fromtimestamp(bar["t"] / 1000), - open=bar["o"], - high=bar["h"], - low=bar["l"], - close=bar["c"], - volume=bar.get("v", 0), - vwap=bar.get("vw"), - transactions=bar.get("n") - ) - - # Check for pagination - next_url = data.get("next_url") - if not next_url: - break - - # Update endpoint for next page - endpoint = next_url.replace(self.base_url, "") - params = {} # next_url includes all params - - async def get_snapshot_forex(self, symbol: str) -> Optional[TickerSnapshot]: - """Get current forex snapshot.""" - formatted_symbol = self._format_symbol(symbol, AssetType.FOREX) - - endpoint = f"/v2/snapshot/locale/global/markets/forex/tickers/{formatted_symbol}" - - try: - data = await self._request(endpoint) - ticker = data.get("ticker", {}) - - if not ticker: - return None - - last_quote = ticker.get("lastQuote", {}) - day = ticker.get("day", {}) - - return TickerSnapshot( - symbol=symbol, - bid=last_quote.get("b", 0), - ask=last_quote.get("a", 0), - last_price=(last_quote.get("b", 0) + last_quote.get("a", 0)) / 2, - timestamp=datetime.fromtimestamp(last_quote.get("t", 0) / 1000000000), - daily_open=day.get("o"), - daily_high=day.get("h"), - daily_low=day.get("l"), - daily_close=day.get("c"), - daily_volume=day.get("v") - ) - except Exception as e: - logger.error(f"Error getting forex snapshot for {symbol}: {e}") - return None - - async def get_snapshot_crypto(self, symbol: str) -> Optional[TickerSnapshot]: - """Get current crypto snapshot.""" - formatted_symbol = self._format_symbol(symbol, AssetType.CRYPTO) - - endpoint = f"/v2/snapshot/locale/global/markets/crypto/tickers/{formatted_symbol}" - - try: - data = await self._request(endpoint) - ticker = data.get("ticker", {}) - - if not ticker: - return None - - last_trade = ticker.get("lastTrade", {}) - day = ticker.get("day", {}) - - return TickerSnapshot( - symbol=symbol, - bid=last_trade.get("p", 0), - ask=last_trade.get("p", 0), - last_price=last_trade.get("p", 0), - timestamp=datetime.fromtimestamp(last_trade.get("t", 0) / 1000000000), - daily_open=day.get("o"), - daily_high=day.get("h"), - daily_low=day.get("l"), - daily_close=day.get("c"), - daily_volume=day.get("v") - ) - except Exception as e: - logger.error(f"Error getting crypto snapshot for {symbol}: {e}") - return None - - async def get_universal_snapshot( - self, - tickers: List[str] - ) -> Dict[str, TickerSnapshot]: - """ - Get snapshots for multiple tickers in one call. - - Args: - tickers: List of formatted tickers (e.g., ['C:EURUSD', 'X:BTCUSD', 'I:SPX']) - - Returns: - Dict mapping ticker to snapshot - """ - ticker_param = ",".join(tickers) - endpoint = f"/v3/snapshot" - - params = {"ticker.any_of": ticker_param} - - try: - data = await self._request(endpoint, params) - results = {} - - for item in data.get("results", []): - ticker = item.get("ticker") - session = item.get("session", {}) - - results[ticker] = TickerSnapshot( - symbol=ticker, - bid=session.get("previous_close", 0), - ask=session.get("previous_close", 0), - last_price=session.get("close", session.get("previous_close", 0)), - timestamp=datetime.now(), - daily_open=session.get("open"), - daily_high=session.get("high"), - daily_low=session.get("low"), - daily_close=session.get("close") - ) - - return results - except Exception as e: - logger.error(f"Error getting universal snapshot: {e}") - return {} - - async def get_ticker_details( - self, - symbol: str, - asset_type: AssetType - ) -> Optional[Dict[str, Any]]: - """Get ticker reference data.""" - formatted_symbol = self._format_symbol(symbol, asset_type) - - endpoint = f"/v3/reference/tickers/{formatted_symbol}" - - try: - data = await self._request(endpoint) - return data.get("results") - except Exception as e: - logger.error(f"Error getting ticker details for {symbol}: {e}") - return None - - -class DataSyncService: - """ - Service to sync market data from Polygon to PostgreSQL. - """ - - def __init__( - self, - polygon_client: PolygonClient, - db_pool, # asyncpg pool - ): - self.client = polygon_client - self.db = db_pool - - async def sync_ticker_data( - self, - ticker_id: int, - symbol: str, - asset_type: AssetType, - start_date: Optional[datetime] = None, - end_date: Optional[datetime] = None, - timeframe: Timeframe = Timeframe.MINUTE_5 - ) -> int: - """ - Sync historical data for a ticker. - - Returns: - Number of rows inserted - """ - # Get last sync timestamp if not provided - if not start_date: - async with self.db.acquire() as conn: - row = await conn.fetchrow( - """ - SELECT MAX(timestamp) as last_ts - FROM market_data.ohlcv_5m - WHERE ticker_id = $1 - """, - ticker_id - ) - start_date = row["last_ts"] or datetime(2015, 1, 1) - start_date = start_date + timedelta(minutes=5) - - if not end_date: - end_date = datetime.now() - - logger.info(f"Syncing {symbol} from {start_date} to {end_date}") - - # Collect bars - bars = [] - async for bar in self.client.get_aggregates( - symbol=symbol, - asset_type=asset_type, - timeframe=timeframe, - start_date=start_date, - end_date=end_date - ): - bars.append(( - ticker_id, - bar.timestamp, - bar.open, - bar.high, - bar.low, - bar.close, - bar.volume, - bar.vwap, - int(bar.timestamp.timestamp()) - )) - - # Insert in batches - if len(bars) >= 10000: - await self._insert_bars(bars) - bars = [] - - # Insert remaining - if bars: - await self._insert_bars(bars) - - logger.info(f"Synced {len(bars)} bars for {symbol}") - return len(bars) - - async def _insert_bars(self, bars: List[tuple]): - """Insert bars into database.""" - async with self.db.acquire() as conn: - await conn.executemany( - """ - INSERT INTO market_data.ohlcv_5m - (ticker_id, timestamp, open, high, low, close, volume, vwap, ts_epoch) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - ON CONFLICT (ticker_id, timestamp) DO UPDATE SET - open = EXCLUDED.open, - high = EXCLUDED.high, - low = EXCLUDED.low, - close = EXCLUDED.close, - volume = EXCLUDED.volume, - vwap = EXCLUDED.vwap - """, - bars - ) - - async def update_sync_status( - self, - ticker_id: int, - provider_id: int, - status: str, - rows: int = 0, - error: Optional[str] = None - ): - """Update sync status in database.""" - async with self.db.acquire() as conn: - await conn.execute( - """ - INSERT INTO data_sources.data_sync_status - (ticker_id, provider_id, last_sync_timestamp, last_sync_rows, sync_status, error_message, updated_at) - VALUES ($1, $2, NOW(), $3, $4, $5, NOW()) - ON CONFLICT (ticker_id, provider_id) DO UPDATE SET - last_sync_timestamp = NOW(), - last_sync_rows = $3, - sync_status = $4, - error_message = $5, - updated_at = NOW() - """, - ticker_id, provider_id, rows, status, error - ) diff --git a/apps/data-service/src/services/__init__.py b/apps/data-service/src/services/__init__.py deleted file mode 100644 index b7e3c4a..0000000 --- a/apps/data-service/src/services/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -"""Services module.""" - -from .price_adjustment import PriceAdjustmentService, SpreadEstimate, TradingSession - -__all__ = [ - "PriceAdjustmentService", - "SpreadEstimate", - "TradingSession", -] diff --git a/apps/data-service/src/services/price_adjustment.py b/apps/data-service/src/services/price_adjustment.py deleted file mode 100644 index 9b35ac1..0000000 --- a/apps/data-service/src/services/price_adjustment.py +++ /dev/null @@ -1,528 +0,0 @@ -""" -Price Adjustment Model Service -OrbiQuant IA Trading Platform - -Handles the adaptation between data source prices (Polygon/Massive) -and broker prices (MT4), accounting for: -- Price offsets between sources -- Spread variations by session -- Volatility-based adjustments -""" - -import asyncio -import numpy as np -from datetime import datetime, timedelta -from typing import Optional, Dict, List, Tuple, Any -from dataclasses import dataclass -from enum import Enum -import logging - -logger = logging.getLogger(__name__) - - -class TradingSession(Enum): - """Trading sessions by UTC hour.""" - ASIAN = "asian" # 00:00 - 08:00 UTC - LONDON = "london" # 08:00 - 12:00 UTC - NEWYORK = "newyork" # 12:00 - 17:00 UTC - OVERLAP = "overlap" # 12:00 - 17:00 UTC (London + NY) - PACIFIC = "pacific" # 21:00 - 00:00 UTC - - -@dataclass -class PriceAdjustmentParams: - """Parameters for price adjustment model.""" - ticker_id: int - model_version: str - - # Base offsets - offset_bid: float = 0.0 - offset_ask: float = 0.0 - - # Session-specific spread multipliers - spread_mult_asian: float = 1.3 # Higher spreads in Asian - spread_mult_london: float = 0.9 # Tighter in London - spread_mult_newyork: float = 0.95 # Tight in NY - spread_mult_overlap: float = 0.85 # Tightest during overlap - spread_mult_pacific: float = 1.2 # Wider in Pacific - - # Volatility adjustments - high_volatility_mult: float = 1.5 # Spread widens 50% in high vol - low_volatility_mult: float = 1.0 - - # Model fit metrics - r_squared: float = 0.0 - mae: float = 0.0 - - -@dataclass -class SpreadEstimate: - """Spread estimate with confidence interval.""" - expected_spread: float - min_spread: float - max_spread: float - confidence: float - session: TradingSession - - -class PriceAdjustmentService: - """ - Service for adjusting data source prices to match broker prices. - - The model accounts for: - 1. Systematic price differences between data source and broker - 2. Session-dependent spread variations - 3. Volatility-dependent spread widening - """ - - # Session hour ranges (UTC) - SESSION_HOURS = { - TradingSession.ASIAN: (0, 8), - TradingSession.LONDON: (8, 12), - TradingSession.OVERLAP: (12, 17), - TradingSession.NEWYORK: (17, 21), - TradingSession.PACIFIC: (21, 24), - } - - # Default spread estimates by asset type (in price units) - DEFAULT_SPREADS = { - "forex_major": 0.00010, # 1 pip for majors - "forex_minor": 0.00020, # 2 pips for minors - "forex_exotic": 0.00050, # 5 pips for exotics - "crypto": 0.001, # 0.1% for crypto - "index": 0.5, # 0.5 points for indices - "commodity": 0.05, # For gold/oil - } - - def __init__(self, db_pool): - self.db = db_pool - self._params_cache: Dict[int, PriceAdjustmentParams] = {} - self._spread_cache: Dict[Tuple[int, str], SpreadEstimate] = {} - - def get_current_session(self, timestamp: datetime = None) -> TradingSession: - """Determine current trading session.""" - if timestamp is None: - timestamp = datetime.utcnow() - - hour = timestamp.hour - - for session, (start, end) in self.SESSION_HOURS.items(): - if start <= hour < end: - return session - - return TradingSession.PACIFIC - - async def get_adjustment_params( - self, - ticker_id: int, - force_refresh: bool = False - ) -> PriceAdjustmentParams: - """Get price adjustment parameters for a ticker.""" - if not force_refresh and ticker_id in self._params_cache: - return self._params_cache[ticker_id] - - async with self.db.acquire() as conn: - row = await conn.fetchrow( - """ - SELECT * - FROM broker_integration.price_adjustment_model - WHERE ticker_id = $1 AND is_active = true - ORDER BY valid_from DESC - LIMIT 1 - """, - ticker_id - ) - - if row: - session_adj = row.get("session_adjustments", {}) or {} - params = PriceAdjustmentParams( - ticker_id=ticker_id, - model_version=row["model_version"], - offset_bid=row["avg_offset_bid"] or 0, - offset_ask=row["avg_offset_ask"] or 0, - spread_mult_asian=session_adj.get("asian", {}).get("spread_mult", 1.3), - spread_mult_london=session_adj.get("london", {}).get("spread_mult", 0.9), - spread_mult_newyork=session_adj.get("newyork", {}).get("spread_mult", 0.95), - spread_mult_overlap=session_adj.get("overlap", {}).get("spread_mult", 0.85), - spread_mult_pacific=session_adj.get("pacific", {}).get("spread_mult", 1.2), - high_volatility_mult=row["scaling_factor_high_volatility"] or 1.5, - low_volatility_mult=row["scaling_factor_low_volatility"] or 1.0, - r_squared=row["r_squared"] or 0, - mae=row["mae"] or 0, - ) - else: - # Default params - params = PriceAdjustmentParams( - ticker_id=ticker_id, - model_version="default_v1" - ) - - self._params_cache[ticker_id] = params - return params - - async def estimate_spread( - self, - ticker_id: int, - timestamp: datetime = None, - volatility_percentile: float = 50.0 - ) -> SpreadEstimate: - """ - Estimate expected spread for a ticker at given time. - - Args: - ticker_id: Ticker ID - timestamp: Target timestamp (default: now) - volatility_percentile: Current volatility percentile (0-100) - - Returns: - SpreadEstimate with expected, min, max spread - """ - if timestamp is None: - timestamp = datetime.utcnow() - - session = self.get_current_session(timestamp) - cache_key = (ticker_id, session.value) - - # Check cache (valid for 5 minutes) - if cache_key in self._spread_cache: - cached = self._spread_cache[cache_key] - # Cache is simple here; production would check timestamp - - # Get historical spread stats - async with self.db.acquire() as conn: - row = await conn.fetchrow( - """ - SELECT - avg_spread, - min_spread, - max_spread, - spread_p95, - std_spread - FROM broker_integration.spread_statistics - WHERE ticker_id = $1 AND session_type = $2 - ORDER BY period_start DESC - LIMIT 1 - """, - ticker_id, session.value - ) - - if row and row["avg_spread"]: - base_spread = row["avg_spread"] - min_spread = row["min_spread"] - max_spread = row["max_spread"] - std_spread = row["std_spread"] or base_spread * 0.2 - else: - # Fallback to defaults - ticker_info = await conn.fetchrow( - "SELECT asset_type, symbol FROM market_data.tickers WHERE id = $1", - ticker_id - ) - - if ticker_info: - asset_type = ticker_info["asset_type"] - symbol = ticker_info["symbol"] - - # Determine spread category - if asset_type == "forex": - if symbol in ["EURUSD", "GBPUSD", "USDJPY", "USDCHF"]: - base_spread = self.DEFAULT_SPREADS["forex_major"] - elif symbol in ["EURJPY", "GBPJPY", "AUDUSD", "NZDUSD"]: - base_spread = self.DEFAULT_SPREADS["forex_minor"] - else: - base_spread = self.DEFAULT_SPREADS["forex_exotic"] - elif asset_type == "crypto": - base_spread = self.DEFAULT_SPREADS["crypto"] - elif asset_type == "index": - base_spread = self.DEFAULT_SPREADS["index"] - else: - base_spread = self.DEFAULT_SPREADS["commodity"] - else: - base_spread = 0.0002 # Generic fallback - - min_spread = base_spread * 0.5 - max_spread = base_spread * 3.0 - std_spread = base_spread * 0.3 - - # Apply session multiplier - params = await self.get_adjustment_params(ticker_id) - session_mult = { - TradingSession.ASIAN: params.spread_mult_asian, - TradingSession.LONDON: params.spread_mult_london, - TradingSession.NEWYORK: params.spread_mult_newyork, - TradingSession.OVERLAP: params.spread_mult_overlap, - TradingSession.PACIFIC: params.spread_mult_pacific, - }.get(session, 1.0) - - # Apply volatility adjustment - if volatility_percentile > 80: - vol_mult = params.high_volatility_mult - elif volatility_percentile < 20: - vol_mult = params.low_volatility_mult - else: - # Linear interpolation - vol_mult = params.low_volatility_mult + ( - (params.high_volatility_mult - params.low_volatility_mult) * - (volatility_percentile - 20) / 60 - ) - - expected_spread = base_spread * session_mult * vol_mult - adjusted_min = min_spread * session_mult - adjusted_max = max_spread * session_mult * vol_mult - - estimate = SpreadEstimate( - expected_spread=expected_spread, - min_spread=adjusted_min, - max_spread=adjusted_max, - confidence=0.95 if row else 0.7, - session=session - ) - - self._spread_cache[cache_key] = estimate - return estimate - - def adjust_price( - self, - price: float, - params: PriceAdjustmentParams, - price_type: str = "mid" # "bid", "ask", "mid" - ) -> float: - """ - Adjust data source price to estimated broker price. - - Args: - price: Original price from data source - params: Adjustment parameters - price_type: Which price to return - - Returns: - Adjusted price - """ - if price_type == "bid": - return price + params.offset_bid - elif price_type == "ask": - return price + params.offset_ask - else: # mid - return price + (params.offset_bid + params.offset_ask) / 2 - - async def calculate_adjusted_entry( - self, - ticker_id: int, - entry_price: float, - stop_loss: float, - take_profit: float, - signal_type: str, # "long" or "short" - timestamp: datetime = None - ) -> Dict[str, float]: - """ - Calculate spread-adjusted entry parameters. - - Returns entry with actual R:R after accounting for spread. - """ - spread_estimate = await self.estimate_spread(ticker_id, timestamp) - spread = spread_estimate.expected_spread - - if signal_type == "long": - # Long: Buy at ASK, sell at BID - # Entry worse by half spread, exit worse by half spread - effective_entry = entry_price + spread / 2 - effective_sl = stop_loss # SL at BID is fine - effective_tp = take_profit # TP at BID is fine - - gross_risk = entry_price - stop_loss - gross_reward = take_profit - entry_price - - # Actual risk/reward after spread - actual_risk = effective_entry - stop_loss # Risk increases - actual_reward = take_profit - effective_entry # Reward decreases - - else: # short - # Short: Sell at BID, buy back at ASK - effective_entry = entry_price - spread / 2 - effective_sl = stop_loss - effective_tp = take_profit - - gross_risk = stop_loss - entry_price - gross_reward = entry_price - take_profit - - actual_risk = stop_loss - effective_entry - actual_reward = effective_entry - take_profit - - gross_rr = gross_reward / gross_risk if gross_risk > 0 else 0 - net_rr = actual_reward / actual_risk if actual_risk > 0 else 0 - - # Calculate minimum required win rate for profitability - # Breakeven: win_rate * reward = (1 - win_rate) * risk - # win_rate = risk / (risk + reward) = 1 / (1 + RR) - min_win_rate = 1 / (1 + net_rr) if net_rr > 0 else 1.0 - - return { - "effective_entry": effective_entry, - "expected_spread": spread, - "spread_session": spread_estimate.session.value, - "gross_rr": round(gross_rr, 2), - "net_rr": round(net_rr, 2), - "rr_reduction_pct": round((1 - net_rr / gross_rr) * 100, 1) if gross_rr > 0 else 0, - "spread_cost_pct": round((spread / entry_price) * 100, 4), - "min_win_rate_for_profit": round(min_win_rate * 100, 1), - "spread_confidence": spread_estimate.confidence, - } - - async def train_adjustment_model( - self, - ticker_id: int, - account_id: int, - days_of_data: int = 30 - ) -> PriceAdjustmentParams: - """ - Train price adjustment model using historical broker vs data source prices. - - This compares actual broker prices with data source prices to find: - 1. Systematic offsets - 2. Session-dependent spread patterns - 3. Volatility correlations - """ - async with self.db.acquire() as conn: - # Get broker prices aligned with data source prices - rows = await conn.fetch( - """ - WITH broker_data AS ( - SELECT - bp.ticker_id, - date_trunc('minute', bp.timestamp) as ts_minute, - AVG(bp.bid) as broker_bid, - AVG(bp.ask) as broker_ask, - AVG(bp.spread_points) as broker_spread - FROM broker_integration.broker_prices bp - WHERE bp.ticker_id = $1 - AND bp.account_id = $2 - AND bp.timestamp > NOW() - INTERVAL '%s days' - GROUP BY bp.ticker_id, ts_minute - ), - source_data AS ( - SELECT - ticker_id, - timestamp as ts_minute, - (open + close) / 2 as source_mid, - high - low as candle_range - FROM market_data.ohlcv_5m - WHERE ticker_id = $1 - AND timestamp > NOW() - INTERVAL '%s days' - ) - SELECT - bd.ts_minute, - bd.broker_bid, - bd.broker_ask, - bd.broker_spread, - sd.source_mid, - sd.candle_range, - EXTRACT(HOUR FROM bd.ts_minute) as hour_utc - FROM broker_data bd - JOIN source_data sd ON bd.ts_minute = sd.ts_minute - ORDER BY bd.ts_minute - """ % (days_of_data, days_of_data), - ticker_id, account_id - ) - - if len(rows) < 100: - logger.warning(f"Insufficient data for training: {len(rows)} rows") - return PriceAdjustmentParams(ticker_id=ticker_id, model_version="default_v1") - - # Calculate offsets - bid_offsets = [] - ask_offsets = [] - session_spreads = {s.value: [] for s in TradingSession} - - for row in rows: - source_mid = row["source_mid"] - broker_mid = (row["broker_bid"] + row["broker_ask"]) / 2 - - bid_offsets.append(row["broker_bid"] - source_mid) - ask_offsets.append(row["broker_ask"] - source_mid) - - # Categorize by session - hour = row["hour_utc"] - session = self._hour_to_session(hour) - session_spreads[session.value].append(row["broker_spread"]) - - # Calculate statistics - avg_bid_offset = float(np.mean(bid_offsets)) - avg_ask_offset = float(np.mean(ask_offsets)) - - # Session spread multipliers (relative to overall average) - overall_avg_spread = float(np.mean([s for spreads in session_spreads.values() for s in spreads])) - - session_mults = {} - for session, spreads in session_spreads.items(): - if spreads: - session_avg = float(np.mean(spreads)) - session_mults[session] = { - "spread_mult": round(session_avg / overall_avg_spread, 2) if overall_avg_spread > 0 else 1.0 - } - else: - session_mults[session] = {"spread_mult": 1.0} - - # Calculate model fit (R-squared for offset prediction) - predictions = [source_mid + (avg_bid_offset + avg_ask_offset) / 2 - for source_mid in [r["source_mid"] for r in rows]] - actuals = [(r["broker_bid"] + r["broker_ask"]) / 2 for r in rows] - - ss_res = sum((p - a) ** 2 for p, a in zip(predictions, actuals)) - ss_tot = sum((a - np.mean(actuals)) ** 2 for a in actuals) - r_squared = 1 - (ss_res / ss_tot) if ss_tot > 0 else 0 - - mae = float(np.mean([abs(p - a) for p, a in zip(predictions, actuals)])) - - # Create new model version - model_version = f"v1_{datetime.now().strftime('%Y%m%d_%H%M')}" - - # Save to database - await conn.execute( - """ - UPDATE broker_integration.price_adjustment_model - SET is_active = false, valid_until = NOW() - WHERE ticker_id = $1 AND is_active = true - """, - ticker_id - ) - - await conn.execute( - """ - INSERT INTO broker_integration.price_adjustment_model - (ticker_id, account_id, model_version, valid_from, - avg_offset_bid, avg_offset_ask, - scaling_factor_high_volatility, scaling_factor_low_volatility, - session_adjustments, r_squared, mae, sample_size, is_active) - VALUES ($1, $2, $3, NOW(), $4, $5, $6, $7, $8, $9, $10, $11, true) - """, - ticker_id, account_id, model_version, - avg_bid_offset, avg_ask_offset, - 1.5, 1.0, # Default volatility factors - session_mults, - r_squared, mae, len(rows) - ) - - params = PriceAdjustmentParams( - ticker_id=ticker_id, - model_version=model_version, - offset_bid=avg_bid_offset, - offset_ask=avg_ask_offset, - spread_mult_asian=session_mults.get("asian", {}).get("spread_mult", 1.3), - spread_mult_london=session_mults.get("london", {}).get("spread_mult", 0.9), - spread_mult_newyork=session_mults.get("newyork", {}).get("spread_mult", 0.95), - spread_mult_overlap=session_mults.get("overlap", {}).get("spread_mult", 0.85), - spread_mult_pacific=session_mults.get("pacific", {}).get("spread_mult", 1.2), - r_squared=r_squared, - mae=mae, - ) - - self._params_cache[ticker_id] = params - logger.info(f"Trained adjustment model {model_version} for ticker {ticker_id}, R²={r_squared:.4f}") - - return params - - def _hour_to_session(self, hour: int) -> TradingSession: - """Convert UTC hour to trading session.""" - for session, (start, end) in self.SESSION_HOURS.items(): - if start <= hour < end: - return session - return TradingSession.PACIFIC diff --git a/apps/data-service/src/services/scheduler.py b/apps/data-service/src/services/scheduler.py deleted file mode 100644 index a9f504d..0000000 --- a/apps/data-service/src/services/scheduler.py +++ /dev/null @@ -1,313 +0,0 @@ -""" -Task Scheduler for Data Synchronization -OrbiQuant IA Trading Platform - -Handles periodic sync tasks using APScheduler -""" - -import asyncio -import logging -from datetime import datetime, timedelta -from typing import Optional, Callable - -from apscheduler.schedulers.asyncio import AsyncIOScheduler -from apscheduler.triggers.interval import IntervalTrigger -from apscheduler.triggers.cron import CronTrigger - -from providers.polygon_client import PolygonClient, Timeframe -from services.sync_service import DataSyncService - -logger = logging.getLogger(__name__) - - -class DataSyncScheduler: - """ - Scheduler for automatic data synchronization. - - Features: - - Periodic sync of all active tickers - - Configurable sync intervals - - Different schedules for different timeframes - - Error handling and retry logic - """ - - def __init__( - self, - sync_service: DataSyncService, - sync_interval_minutes: int = 5 - ): - self.sync_service = sync_service - self.sync_interval_minutes = sync_interval_minutes - self.scheduler = AsyncIOScheduler() - self._is_running = False - - async def start(self): - """Start the scheduler.""" - if self._is_running: - logger.warning("Scheduler already running") - return - - logger.info("Starting data sync scheduler") - - # Schedule 1-minute data sync every minute - self.scheduler.add_job( - self._sync_1min_data, - trigger=IntervalTrigger(minutes=1), - id="sync_1min", - name="Sync 1-minute data", - replace_existing=True, - max_instances=1 - ) - - # Schedule 5-minute data sync every 5 minutes - self.scheduler.add_job( - self._sync_5min_data, - trigger=IntervalTrigger(minutes=5), - id="sync_5min", - name="Sync 5-minute data", - replace_existing=True, - max_instances=1 - ) - - # Schedule 15-minute data sync every 15 minutes - self.scheduler.add_job( - self._sync_15min_data, - trigger=IntervalTrigger(minutes=15), - id="sync_15min", - name="Sync 15-minute data", - replace_existing=True, - max_instances=1 - ) - - # Schedule 1-hour data sync every hour - self.scheduler.add_job( - self._sync_1hour_data, - trigger=IntervalTrigger(hours=1), - id="sync_1hour", - name="Sync 1-hour data", - replace_existing=True, - max_instances=1 - ) - - # Schedule 4-hour data sync every 4 hours - self.scheduler.add_job( - self._sync_4hour_data, - trigger=IntervalTrigger(hours=4), - id="sync_4hour", - name="Sync 4-hour data", - replace_existing=True, - max_instances=1 - ) - - # Schedule daily data sync at midnight UTC - self.scheduler.add_job( - self._sync_daily_data, - trigger=CronTrigger(hour=0, minute=5), - id="sync_daily", - name="Sync daily data", - replace_existing=True, - max_instances=1 - ) - - # Schedule cleanup old data weekly - self.scheduler.add_job( - self._cleanup_old_data, - trigger=CronTrigger(day_of_week="sun", hour=2, minute=0), - id="cleanup_old_data", - name="Cleanup old data", - replace_existing=True, - max_instances=1 - ) - - # Start scheduler - self.scheduler.start() - self._is_running = True - - logger.info(f"Scheduler started with {len(self.scheduler.get_jobs())} jobs") - - async def stop(self): - """Stop the scheduler.""" - if not self._is_running: - return - - logger.info("Stopping data sync scheduler") - self.scheduler.shutdown(wait=True) - self._is_running = False - logger.info("Scheduler stopped") - - def get_jobs(self): - """Get list of scheduled jobs.""" - return [ - { - "id": job.id, - "name": job.name, - "next_run": job.next_run_time.isoformat() if job.next_run_time else None, - "trigger": str(job.trigger) - } - for job in self.scheduler.get_jobs() - ] - - # ============================================================================= - # Sync Tasks - # ============================================================================= - - async def _sync_1min_data(self): - """Sync 1-minute data for all active tickers.""" - logger.info("Starting 1-minute data sync") - try: - result = await self.sync_service.sync_all_active_tickers( - timeframe=Timeframe.MINUTE_1, - backfill_days=1 # Only sync last day for minute data - ) - logger.info( - f"1-minute sync completed: {result['successful']}/{result['total_tickers']} " - f"successful, {result['total_rows_inserted']} rows" - ) - except Exception as e: - logger.error(f"Error in 1-minute sync: {e}", exc_info=True) - - async def _sync_5min_data(self): - """Sync 5-minute data for all active tickers.""" - logger.info("Starting 5-minute data sync") - try: - result = await self.sync_service.sync_all_active_tickers( - timeframe=Timeframe.MINUTE_5, - backfill_days=1 - ) - logger.info( - f"5-minute sync completed: {result['successful']}/{result['total_tickers']} " - f"successful, {result['total_rows_inserted']} rows" - ) - except Exception as e: - logger.error(f"Error in 5-minute sync: {e}", exc_info=True) - - async def _sync_15min_data(self): - """Sync 15-minute data for all active tickers.""" - logger.info("Starting 15-minute data sync") - try: - result = await self.sync_service.sync_all_active_tickers( - timeframe=Timeframe.MINUTE_15, - backfill_days=2 - ) - logger.info( - f"15-minute sync completed: {result['successful']}/{result['total_tickers']} " - f"successful, {result['total_rows_inserted']} rows" - ) - except Exception as e: - logger.error(f"Error in 15-minute sync: {e}", exc_info=True) - - async def _sync_1hour_data(self): - """Sync 1-hour data for all active tickers.""" - logger.info("Starting 1-hour data sync") - try: - result = await self.sync_service.sync_all_active_tickers( - timeframe=Timeframe.HOUR_1, - backfill_days=7 - ) - logger.info( - f"1-hour sync completed: {result['successful']}/{result['total_tickers']} " - f"successful, {result['total_rows_inserted']} rows" - ) - except Exception as e: - logger.error(f"Error in 1-hour sync: {e}", exc_info=True) - - async def _sync_4hour_data(self): - """Sync 4-hour data for all active tickers.""" - logger.info("Starting 4-hour data sync") - try: - result = await self.sync_service.sync_all_active_tickers( - timeframe=Timeframe.HOUR_4, - backfill_days=30 - ) - logger.info( - f"4-hour sync completed: {result['successful']}/{result['total_tickers']} " - f"successful, {result['total_rows_inserted']} rows" - ) - except Exception as e: - logger.error(f"Error in 4-hour sync: {e}", exc_info=True) - - async def _sync_daily_data(self): - """Sync daily data for all active tickers.""" - logger.info("Starting daily data sync") - try: - result = await self.sync_service.sync_all_active_tickers( - timeframe=Timeframe.DAY_1, - backfill_days=90 - ) - logger.info( - f"Daily sync completed: {result['successful']}/{result['total_tickers']} " - f"successful, {result['total_rows_inserted']} rows" - ) - except Exception as e: - logger.error(f"Error in daily sync: {e}", exc_info=True) - - async def _cleanup_old_data(self): - """Clean up old data to save space.""" - logger.info("Starting old data cleanup") - try: - # Example: Delete 1-minute data older than 7 days - async with self.sync_service.db.acquire() as conn: - # 1-minute data: keep 7 days - deleted_1min = await conn.fetchval( - """ - DELETE FROM market_data.ohlcv_1min - WHERE timestamp < NOW() - INTERVAL '7 days' - RETURNING COUNT(*) - """ - ) - - # 5-minute data: keep 30 days - deleted_5min = await conn.fetchval( - """ - DELETE FROM market_data.ohlcv_5min - WHERE timestamp < NOW() - INTERVAL '30 days' - RETURNING COUNT(*) - """ - ) - - # 15-minute data: keep 90 days - deleted_15min = await conn.fetchval( - """ - DELETE FROM market_data.ohlcv_15min - WHERE timestamp < NOW() - INTERVAL '90 days' - RETURNING COUNT(*) - """ - ) - - logger.info( - f"Cleanup completed: {deleted_1min} 1min, " - f"{deleted_5min} 5min, {deleted_15min} 15min rows deleted" - ) - - except Exception as e: - logger.error(f"Error in cleanup: {e}", exc_info=True) - - -class SchedulerManager: - """ - Manager for the data sync scheduler singleton. - """ - _instance: Optional[DataSyncScheduler] = None - - @classmethod - async def get_instance( - cls, - sync_service: DataSyncService, - sync_interval_minutes: int = 5 - ) -> DataSyncScheduler: - """Get or create scheduler instance.""" - if cls._instance is None: - cls._instance = DataSyncScheduler( - sync_service=sync_service, - sync_interval_minutes=sync_interval_minutes - ) - await cls._instance.start() - - return cls._instance - - @classmethod - async def stop_instance(cls): - """Stop scheduler instance.""" - if cls._instance: - await cls._instance.stop() - cls._instance = None diff --git a/apps/data-service/src/services/sync_service.py b/apps/data-service/src/services/sync_service.py deleted file mode 100644 index 7ee4079..0000000 --- a/apps/data-service/src/services/sync_service.py +++ /dev/null @@ -1,500 +0,0 @@ -""" -Data Synchronization Service -OrbiQuant IA Trading Platform - -Handles automatic synchronization of market data from Massive.com/Polygon.io -""" - -import asyncio -import logging -from datetime import datetime, timedelta -from typing import Optional, List, Dict, Any -from enum import Enum - -import asyncpg - -from providers.polygon_client import PolygonClient, AssetType, Timeframe, OHLCVBar -from config import TICKER_MAPPINGS - -logger = logging.getLogger(__name__) - - -class SyncStatus(str, Enum): - """Sync status values.""" - PENDING = "pending" - IN_PROGRESS = "in_progress" - SUCCESS = "success" - FAILED = "failed" - PARTIAL = "partial" - - -class DataSyncService: - """ - Service to sync market data from Polygon/Massive to PostgreSQL. - - Features: - - Automatic backfill of historical data - - Incremental sync from last timestamp - - Multi-timeframe support - - Rate limiting and error handling - - Sync status tracking - """ - - # Supported timeframes with their table mappings - TIMEFRAME_TABLES = { - Timeframe.MINUTE_1: "ohlcv_1min", - Timeframe.MINUTE_5: "ohlcv_5min", - Timeframe.MINUTE_15: "ohlcv_15min", - Timeframe.HOUR_1: "ohlcv_1hour", - Timeframe.HOUR_4: "ohlcv_4hour", - Timeframe.DAY_1: "ohlcv_daily", - } - - def __init__( - self, - polygon_client: PolygonClient, - db_pool: asyncpg.Pool, - batch_size: int = 10000 - ): - self.client = polygon_client - self.db = db_pool - self.batch_size = batch_size - self._sync_tasks: Dict[str, asyncio.Task] = {} - - async def get_or_create_ticker( - self, - symbol: str, - asset_type: AssetType - ) -> Optional[int]: - """ - Get ticker ID from database or create new ticker entry. - - Args: - symbol: Ticker symbol (e.g., 'EURUSD', 'BTCUSD') - asset_type: Type of asset - - Returns: - Ticker ID or None if error - """ - async with self.db.acquire() as conn: - # Try to get existing ticker - row = await conn.fetchrow( - """ - SELECT id FROM market_data.tickers - WHERE UPPER(symbol) = UPPER($1) - """, - symbol - ) - - if row: - return row["id"] - - # Create new ticker - try: - # Get ticker details from Polygon - details = await self.client.get_ticker_details(symbol, asset_type) - - ticker_id = await conn.fetchval( - """ - INSERT INTO market_data.tickers - (symbol, name, asset_type, base_currency, quote_currency, - exchange, is_active, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW()) - RETURNING id - """, - symbol.upper(), - details.get("name") if details else symbol, - asset_type.value, - symbol[:3] if len(symbol) >= 6 else "USD", # Basic parsing - symbol[3:] if len(symbol) >= 6 else "USD", - details.get("primary_exchange") if details else "POLYGON", - True - ) - - logger.info(f"Created new ticker: {symbol} (ID: {ticker_id})") - return ticker_id - - except Exception as e: - logger.error(f"Error creating ticker {symbol}: {e}") - return None - - async def sync_ticker_data( - self, - symbol: str, - asset_type: AssetType, - timeframe: Timeframe = Timeframe.MINUTE_5, - start_date: Optional[datetime] = None, - end_date: Optional[datetime] = None, - backfill_days: int = 30 - ) -> Dict[str, Any]: - """ - Sync historical data for a ticker. - - Args: - symbol: Ticker symbol - asset_type: Type of asset - timeframe: Data timeframe - start_date: Start date (if None, uses last sync or backfill_days) - end_date: End date (if None, uses current time) - backfill_days: Days to backfill if no previous data - - Returns: - Dict with sync results (rows_inserted, status, etc.) - """ - logger.info(f"Starting sync for {symbol} ({asset_type.value}) - {timeframe.value}") - - # Get or create ticker - ticker_id = await self.get_or_create_ticker(symbol, asset_type) - if not ticker_id: - return { - "status": SyncStatus.FAILED, - "error": "Failed to get/create ticker", - "rows_inserted": 0 - } - - # Get table name - table_name = self.TIMEFRAME_TABLES.get(timeframe, "ohlcv_5min") - - # Determine time range - if not start_date: - async with self.db.acquire() as conn: - row = await conn.fetchrow( - f""" - SELECT MAX(timestamp) as last_ts - FROM market_data.{table_name} - WHERE ticker_id = $1 - """, - ticker_id - ) - - if row["last_ts"]: - # Continue from last sync - start_date = row["last_ts"] + timedelta(minutes=1) - logger.info(f"Continuing from last sync: {start_date}") - else: - # Backfill from N days ago - start_date = datetime.now() - timedelta(days=backfill_days) - logger.info(f"Starting backfill from {backfill_days} days ago") - - if not end_date: - end_date = datetime.now() - - # Prevent syncing future data - if start_date >= end_date: - logger.warning(f"Start date >= end date, nothing to sync") - return { - "status": SyncStatus.SUCCESS, - "rows_inserted": 0, - "message": "Already up to date" - } - - # Collect bars from API - bars = [] - total_bars = 0 - - try: - async for bar in self.client.get_aggregates( - symbol=symbol, - asset_type=asset_type, - timeframe=timeframe, - start_date=start_date, - end_date=end_date, - adjusted=True, - limit=50000 - ): - bars.append(( - ticker_id, - bar.timestamp, - float(bar.open), - float(bar.high), - float(bar.low), - float(bar.close), - float(bar.volume) if bar.volume else 0.0, - float(bar.vwap) if bar.vwap else None, - bar.transactions, - int(bar.timestamp.timestamp()) - )) - - # Insert in batches - if len(bars) >= self.batch_size: - inserted = await self._insert_bars(table_name, bars) - total_bars += inserted - bars = [] - - # Insert remaining bars - if bars: - inserted = await self._insert_bars(table_name, bars) - total_bars += inserted - - # Update sync status - await self._update_sync_status( - ticker_id=ticker_id, - status=SyncStatus.SUCCESS, - rows=total_bars, - timeframe=timeframe.value - ) - - logger.info(f"Sync completed for {symbol}: {total_bars} bars inserted") - - return { - "status": SyncStatus.SUCCESS, - "symbol": symbol, - "timeframe": timeframe.value, - "rows_inserted": total_bars, - "start_date": start_date.isoformat(), - "end_date": end_date.isoformat() - } - - except Exception as e: - logger.error(f"Error syncing {symbol}: {e}", exc_info=True) - - # Update sync status with error - await self._update_sync_status( - ticker_id=ticker_id, - status=SyncStatus.FAILED, - rows=total_bars, - error=str(e), - timeframe=timeframe.value - ) - - return { - "status": SyncStatus.FAILED, - "symbol": symbol, - "error": str(e), - "rows_inserted": total_bars - } - - async def _insert_bars( - self, - table_name: str, - bars: List[tuple] - ) -> int: - """ - Insert bars into database with conflict handling. - - Args: - table_name: Target table name - bars: List of bar tuples - - Returns: - Number of rows inserted/updated - """ - if not bars: - return 0 - - async with self.db.acquire() as conn: - # Use ON CONFLICT to handle duplicates - await conn.executemany( - f""" - INSERT INTO market_data.{table_name} - (ticker_id, timestamp, open, high, low, close, volume, vwap, trades, ts_epoch) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - ON CONFLICT (ticker_id, timestamp) DO UPDATE SET - open = EXCLUDED.open, - high = EXCLUDED.high, - low = EXCLUDED.low, - close = EXCLUDED.close, - volume = EXCLUDED.volume, - vwap = EXCLUDED.vwap, - trades = EXCLUDED.trades - """, - bars - ) - - return len(bars) - - async def _update_sync_status( - self, - ticker_id: int, - status: SyncStatus, - rows: int = 0, - error: Optional[str] = None, - timeframe: str = "5min" - ): - """Update sync status in database.""" - async with self.db.acquire() as conn: - await conn.execute( - """ - INSERT INTO market_data.sync_status - (ticker_id, timeframe, last_sync_timestamp, last_sync_rows, - sync_status, error_message, updated_at) - VALUES ($1, $2, NOW(), $3, $4, $5, NOW()) - ON CONFLICT (ticker_id, timeframe) DO UPDATE SET - last_sync_timestamp = NOW(), - last_sync_rows = $3, - sync_status = $4, - error_message = $5, - updated_at = NOW() - """, - ticker_id, timeframe, rows, status.value, error - ) - - async def sync_all_active_tickers( - self, - timeframe: Timeframe = Timeframe.MINUTE_5, - backfill_days: int = 1 - ) -> Dict[str, Any]: - """ - Sync all active tickers from database. - - Args: - timeframe: Timeframe to sync - backfill_days: Days to backfill for new data - - Returns: - Summary of sync results - """ - logger.info("Starting sync for all active tickers") - - # Get active tickers - async with self.db.acquire() as conn: - rows = await conn.fetch( - """ - SELECT id, symbol, asset_type - FROM market_data.tickers - WHERE is_active = true - ORDER BY symbol - """ - ) - - results = [] - for row in rows: - try: - asset_type = AssetType(row["asset_type"]) - result = await self.sync_ticker_data( - symbol=row["symbol"], - asset_type=asset_type, - timeframe=timeframe, - backfill_days=backfill_days - ) - results.append(result) - - # Small delay to respect rate limits - await asyncio.sleep(0.5) - - except Exception as e: - logger.error(f"Error syncing {row['symbol']}: {e}") - results.append({ - "status": SyncStatus.FAILED, - "symbol": row["symbol"], - "error": str(e) - }) - - # Calculate summary - total = len(results) - success = sum(1 for r in results if r["status"] == SyncStatus.SUCCESS) - failed = total - success - total_rows = sum(r.get("rows_inserted", 0) for r in results) - - summary = { - "total_tickers": total, - "successful": success, - "failed": failed, - "total_rows_inserted": total_rows, - "results": results - } - - logger.info(f"Sync completed: {success}/{total} successful, {total_rows} rows") - return summary - - async def get_sync_status( - self, - symbol: Optional[str] = None - ) -> List[Dict[str, Any]]: - """ - Get sync status for tickers. - - Args: - symbol: Optional symbol to filter by - - Returns: - List of sync status records - """ - async with self.db.acquire() as conn: - if symbol: - rows = await conn.fetch( - """ - SELECT - t.symbol, t.asset_type, s.timeframe, - s.last_sync_timestamp, s.last_sync_rows, - s.sync_status, s.error_message, s.updated_at - FROM market_data.tickers t - LEFT JOIN market_data.sync_status s ON s.ticker_id = t.id - WHERE UPPER(t.symbol) = UPPER($1) - ORDER BY s.timeframe - """, - symbol - ) - else: - rows = await conn.fetch( - """ - SELECT - t.symbol, t.asset_type, s.timeframe, - s.last_sync_timestamp, s.last_sync_rows, - s.sync_status, s.error_message, s.updated_at - FROM market_data.tickers t - LEFT JOIN market_data.sync_status s ON s.ticker_id = t.id - WHERE t.is_active = true - ORDER BY t.symbol, s.timeframe - LIMIT 100 - """ - ) - - return [ - { - "symbol": row["symbol"], - "asset_type": row["asset_type"], - "timeframe": row["timeframe"], - "last_sync": row["last_sync_timestamp"].isoformat() if row["last_sync_timestamp"] else None, - "rows_synced": row["last_sync_rows"], - "status": row["sync_status"], - "error": row["error_message"], - "updated_at": row["updated_at"].isoformat() if row["updated_at"] else None - } - for row in rows - ] - - async def get_supported_symbols( - self, - asset_type: Optional[AssetType] = None - ) -> List[Dict[str, Any]]: - """ - Get list of supported symbols for Polygon/Massive. - - This returns symbols from our config that we support. - - Args: - asset_type: Optional filter by asset type - - Returns: - List of supported symbols with metadata - """ - symbols = [] - - for symbol, mapping in TICKER_MAPPINGS.items(): - # Determine asset type from prefix - polygon_symbol = mapping["polygon"] - - if polygon_symbol.startswith("C:"): - detected_type = AssetType.FOREX - elif polygon_symbol.startswith("X:"): - detected_type = AssetType.CRYPTO - elif polygon_symbol.startswith("I:"): - detected_type = AssetType.INDEX - else: - detected_type = AssetType.STOCK - - # Filter by asset type if specified - if asset_type and detected_type != asset_type: - continue - - symbols.append({ - "symbol": symbol, - "polygon_symbol": polygon_symbol, - "mt4_symbol": mapping.get("mt4"), - "asset_type": detected_type.value, - "pip_value": mapping.get("pip_value"), - "supported": True - }) - - return symbols diff --git a/apps/data-service/src/websocket/__init__.py b/apps/data-service/src/websocket/__init__.py deleted file mode 100644 index 8bda7e9..0000000 --- a/apps/data-service/src/websocket/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -""" -WebSocket Module -OrbiQuant IA Trading Platform - Data Service -""" - -from .manager import WebSocketManager, ConnectionManager -from .handlers import WSRouter - -__all__ = ["WebSocketManager", "ConnectionManager", "WSRouter"] diff --git a/apps/data-service/src/websocket/handlers.py b/apps/data-service/src/websocket/handlers.py deleted file mode 100644 index c6ac7c7..0000000 --- a/apps/data-service/src/websocket/handlers.py +++ /dev/null @@ -1,184 +0,0 @@ -""" -WebSocket Route Handlers -OrbiQuant IA Trading Platform - Data Service -""" - -import asyncio -import logging -import uuid -from datetime import datetime -from typing import Optional - -from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query -from fastapi.websockets import WebSocketState - -from .manager import WebSocketManager, ConnectionManager - -logger = logging.getLogger(__name__) - -# Global WebSocket manager instance -_ws_manager: Optional[WebSocketManager] = None - - -def get_ws_manager() -> WebSocketManager: - """Get or create WebSocket manager.""" - global _ws_manager - if _ws_manager is None: - _ws_manager = WebSocketManager() - return _ws_manager - - -def set_ws_manager(manager: WebSocketManager) -> None: - """Set the WebSocket manager instance.""" - global _ws_manager - _ws_manager = manager - - -class WSRouter: - """WebSocket router with handlers.""" - - def __init__(self, ws_manager: Optional[WebSocketManager] = None): - self.router = APIRouter() - self.ws_manager = ws_manager or get_ws_manager() - self._setup_routes() - - def _setup_routes(self): - """Setup WebSocket routes.""" - - @self.router.websocket("/ws/stream") - async def websocket_stream( - websocket: WebSocket, - client_id: Optional[str] = Query(None) - ): - """ - Main WebSocket endpoint for real-time data streaming. - - Connect and subscribe to channels: - - ticker: Real-time price updates - - candles: OHLCV candle updates - - orderbook: Order book snapshots - - trades: Recent trades - - signals: Trading signals from ML models - - Example message format: - ```json - { - "action": "subscribe", - "channel": "ticker", - "symbols": ["EURUSD", "BTCUSD"] - } - ``` - """ - # Generate client ID if not provided - if not client_id: - client_id = f"client_{uuid.uuid4().hex[:12]}" - - # Accept connection - client = await self.ws_manager.connections.connect(websocket, client_id) - - # Send welcome message - await websocket.send_json({ - "type": "connected", - "client_id": client_id, - "message": "Connected to OrbiQuant Data Service", - "timestamp": datetime.utcnow().isoformat(), - "available_channels": ["ticker", "candles", "orderbook", "trades", "signals"] - }) - - try: - while True: - # Receive message - try: - data = await asyncio.wait_for( - websocket.receive_json(), - timeout=60.0 # Heartbeat timeout - ) - except asyncio.TimeoutError: - # Send ping to keep connection alive - if websocket.client_state == WebSocketState.CONNECTED: - await websocket.send_json({ - "type": "ping", - "timestamp": datetime.utcnow().isoformat() - }) - continue - - # Handle message - response = await self.ws_manager.handle_message(client_id, data) - await websocket.send_json(response) - - except WebSocketDisconnect: - logger.info(f"Client {client_id} disconnected normally") - except Exception as e: - logger.error(f"WebSocket error for {client_id}: {e}") - finally: - await self.ws_manager.connections.disconnect(client_id) - - @self.router.websocket("/ws/ticker/{symbol}") - async def websocket_ticker( - websocket: WebSocket, - symbol: str - ): - """ - Simplified ticker WebSocket for a single symbol. - - Automatically subscribes to the ticker channel for the specified symbol. - """ - client_id = f"ticker_{uuid.uuid4().hex[:8]}" - - client = await self.ws_manager.connections.connect(websocket, client_id) - await self.ws_manager.connections.subscribe( - client_id=client_id, - channel=self.ws_manager.connections.__class__.__bases__[0].__subclasses__()[0], # Channel.TICKER workaround - symbol=symbol - ) - - # Import here to avoid circular - from .manager import Channel - - await self.ws_manager.connections.subscribe( - client_id=client_id, - channel=Channel.TICKER, - symbol=symbol - ) - - await websocket.send_json({ - "type": "subscribed", - "channel": "ticker", - "symbol": symbol.upper(), - "timestamp": datetime.utcnow().isoformat() - }) - - try: - while True: - # Keep connection alive, data comes via broadcasts - try: - data = await asyncio.wait_for( - websocket.receive_json(), - timeout=30.0 - ) - # Handle ping/pong - if data.get("type") == "ping": - await websocket.send_json({ - "type": "pong", - "timestamp": datetime.utcnow().isoformat() - }) - except asyncio.TimeoutError: - # Send heartbeat - await websocket.send_json({ - "type": "heartbeat", - "timestamp": datetime.utcnow().isoformat() - }) - - except WebSocketDisconnect: - pass - finally: - await self.ws_manager.connections.disconnect(client_id) - - @self.router.get("/ws/stats") - async def websocket_stats(): - """Get WebSocket connection statistics.""" - return { - "status": "ok", - "stats": self.ws_manager.connections.stats, - "timestamp": datetime.utcnow().isoformat() - } diff --git a/apps/data-service/src/websocket/manager.py b/apps/data-service/src/websocket/manager.py deleted file mode 100644 index 6328e50..0000000 --- a/apps/data-service/src/websocket/manager.py +++ /dev/null @@ -1,439 +0,0 @@ -""" -WebSocket Connection Manager -OrbiQuant IA Trading Platform - Data Service - -Handles WebSocket connections, subscriptions, and message broadcasting. -""" - -import asyncio -import json -import logging -from dataclasses import dataclass, field -from datetime import datetime -from typing import Dict, List, Optional, Set, Any -from enum import Enum - -from fastapi import WebSocket, WebSocketDisconnect - -logger = logging.getLogger(__name__) - - -class Channel(str, Enum): - """Available subscription channels.""" - TICKER = "ticker" - CANDLES = "candles" - ORDERBOOK = "orderbook" - TRADES = "trades" - SIGNALS = "signals" - - -@dataclass -class Subscription: - """Client subscription.""" - channel: Channel - symbol: str - timeframe: Optional[str] = None # For candles - - -@dataclass -class ClientConnection: - """Represents a connected WebSocket client.""" - websocket: WebSocket - client_id: str - subscriptions: Set[str] = field(default_factory=set) # "channel:symbol:timeframe" - connected_at: datetime = field(default_factory=datetime.utcnow) - last_activity: datetime = field(default_factory=datetime.utcnow) - message_count: int = 0 - - def add_subscription(self, channel: Channel, symbol: str, timeframe: Optional[str] = None) -> str: - """Add a subscription and return the key.""" - key = f"{channel.value}:{symbol.upper()}" - if timeframe: - key += f":{timeframe}" - self.subscriptions.add(key) - return key - - def remove_subscription(self, channel: Channel, symbol: str, timeframe: Optional[str] = None) -> str: - """Remove a subscription and return the key.""" - key = f"{channel.value}:{symbol.upper()}" - if timeframe: - key += f":{timeframe}" - self.subscriptions.discard(key) - return key - - -class ConnectionManager: - """ - Manages WebSocket connections and message routing. - - Thread-safe implementation using asyncio locks. - """ - - def __init__(self): - # client_id -> ClientConnection - self._clients: Dict[str, ClientConnection] = {} - - # subscription_key -> set of client_ids - self._subscriptions: Dict[str, Set[str]] = {} - - self._lock = asyncio.Lock() - self._stats = { - "total_connections": 0, - "total_messages_sent": 0, - "total_messages_received": 0, - } - - @property - def active_connections(self) -> int: - """Number of active connections.""" - return len(self._clients) - - @property - def stats(self) -> Dict[str, Any]: - """Get connection statistics.""" - return { - **self._stats, - "active_connections": self.active_connections, - "active_subscriptions": len(self._subscriptions), - } - - async def connect(self, websocket: WebSocket, client_id: str) -> ClientConnection: - """Accept a new WebSocket connection.""" - await websocket.accept() - - async with self._lock: - client = ClientConnection( - websocket=websocket, - client_id=client_id - ) - self._clients[client_id] = client - self._stats["total_connections"] += 1 - - logger.info(f"Client {client_id} connected. Total: {self.active_connections}") - return client - - async def disconnect(self, client_id: str) -> None: - """Handle client disconnection.""" - async with self._lock: - client = self._clients.pop(client_id, None) - if client: - # Remove from all subscriptions - for sub_key in client.subscriptions: - if sub_key in self._subscriptions: - self._subscriptions[sub_key].discard(client_id) - if not self._subscriptions[sub_key]: - del self._subscriptions[sub_key] - - logger.info(f"Client {client_id} disconnected. Total: {self.active_connections}") - - async def subscribe( - self, - client_id: str, - channel: Channel, - symbol: str, - timeframe: Optional[str] = None - ) -> bool: - """Subscribe a client to a channel.""" - async with self._lock: - client = self._clients.get(client_id) - if not client: - return False - - sub_key = client.add_subscription(channel, symbol, timeframe) - - if sub_key not in self._subscriptions: - self._subscriptions[sub_key] = set() - self._subscriptions[sub_key].add(client_id) - - logger.debug(f"Client {client_id} subscribed to {sub_key}") - return True - - async def unsubscribe( - self, - client_id: str, - channel: Channel, - symbol: str, - timeframe: Optional[str] = None - ) -> bool: - """Unsubscribe a client from a channel.""" - async with self._lock: - client = self._clients.get(client_id) - if not client: - return False - - sub_key = client.remove_subscription(channel, symbol, timeframe) - - if sub_key in self._subscriptions: - self._subscriptions[sub_key].discard(client_id) - if not self._subscriptions[sub_key]: - del self._subscriptions[sub_key] - - logger.debug(f"Client {client_id} unsubscribed from {sub_key}") - return True - - async def send_personal(self, client_id: str, message: dict) -> bool: - """Send a message to a specific client.""" - client = self._clients.get(client_id) - if not client: - return False - - try: - await client.websocket.send_json(message) - client.message_count += 1 - client.last_activity = datetime.utcnow() - self._stats["total_messages_sent"] += 1 - return True - except Exception as e: - logger.warning(f"Failed to send to client {client_id}: {e}") - return False - - async def broadcast(self, message: dict) -> int: - """Broadcast a message to all connected clients.""" - sent_count = 0 - disconnected = [] - - for client_id, client in list(self._clients.items()): - try: - await client.websocket.send_json(message) - client.message_count += 1 - sent_count += 1 - except Exception: - disconnected.append(client_id) - - # Clean up disconnected clients - for client_id in disconnected: - await self.disconnect(client_id) - - self._stats["total_messages_sent"] += sent_count - return sent_count - - async def broadcast_to_channel( - self, - channel: Channel, - symbol: str, - message: dict, - timeframe: Optional[str] = None - ) -> int: - """Broadcast a message to all clients subscribed to a channel.""" - sub_key = f"{channel.value}:{symbol.upper()}" - if timeframe: - sub_key += f":{timeframe}" - - client_ids = self._subscriptions.get(sub_key, set()) - if not client_ids: - return 0 - - sent_count = 0 - disconnected = [] - - for client_id in list(client_ids): - client = self._clients.get(client_id) - if not client: - disconnected.append(client_id) - continue - - try: - await client.websocket.send_json(message) - client.message_count += 1 - sent_count += 1 - except Exception: - disconnected.append(client_id) - - # Clean up - for client_id in disconnected: - await self.disconnect(client_id) - - self._stats["total_messages_sent"] += sent_count - return sent_count - - def get_subscribers(self, channel: Channel, symbol: str, timeframe: Optional[str] = None) -> Set[str]: - """Get all client IDs subscribed to a channel.""" - sub_key = f"{channel.value}:{symbol.upper()}" - if timeframe: - sub_key += f":{timeframe}" - return self._subscriptions.get(sub_key, set()).copy() - - -class WebSocketManager: - """ - High-level WebSocket manager with market data streaming. - - Integrates with data providers for real-time updates. - """ - - def __init__(self, connection_manager: Optional[ConnectionManager] = None): - self.connections = connection_manager or ConnectionManager() - self._streaming_tasks: Dict[str, asyncio.Task] = {} - self._running = False - - async def start(self): - """Start the WebSocket manager.""" - self._running = True - logger.info("WebSocket manager started") - - async def stop(self): - """Stop the WebSocket manager and cancel all streaming tasks.""" - self._running = False - - for task in self._streaming_tasks.values(): - task.cancel() - - self._streaming_tasks.clear() - logger.info("WebSocket manager stopped") - - async def handle_message(self, client_id: str, message: dict) -> dict: - """ - Handle incoming WebSocket message. - - Returns response to send back to client. - """ - action = message.get("action", "").lower() - - if action == "subscribe": - return await self._handle_subscribe(client_id, message) - elif action == "unsubscribe": - return await self._handle_unsubscribe(client_id, message) - elif action == "ping": - return {"type": "pong", "timestamp": datetime.utcnow().isoformat()} - else: - return { - "type": "error", - "error": f"Unknown action: {action}", - "valid_actions": ["subscribe", "unsubscribe", "ping"] - } - - async def _handle_subscribe(self, client_id: str, message: dict) -> dict: - """Handle subscription request.""" - try: - channel = Channel(message.get("channel", "ticker")) - except ValueError: - return { - "type": "error", - "error": f"Invalid channel. Valid: {[c.value for c in Channel]}" - } - - symbols = message.get("symbols", []) - if not symbols: - return {"type": "error", "error": "No symbols specified"} - - timeframe = message.get("timeframe") - subscribed = [] - - for symbol in symbols: - success = await self.connections.subscribe( - client_id=client_id, - channel=channel, - symbol=symbol, - timeframe=timeframe - ) - if success: - subscribed.append(symbol) - - return { - "type": "subscribed", - "channel": channel.value, - "symbols": subscribed, - "timeframe": timeframe, - "timestamp": datetime.utcnow().isoformat() - } - - async def _handle_unsubscribe(self, client_id: str, message: dict) -> dict: - """Handle unsubscription request.""" - try: - channel = Channel(message.get("channel", "ticker")) - except ValueError: - return {"type": "error", "error": "Invalid channel"} - - symbols = message.get("symbols", []) - timeframe = message.get("timeframe") - unsubscribed = [] - - for symbol in symbols: - success = await self.connections.unsubscribe( - client_id=client_id, - channel=channel, - symbol=symbol, - timeframe=timeframe - ) - if success: - unsubscribed.append(symbol) - - return { - "type": "unsubscribed", - "channel": channel.value, - "symbols": unsubscribed, - "timestamp": datetime.utcnow().isoformat() - } - - async def publish_ticker(self, symbol: str, data: dict) -> int: - """Publish ticker update to subscribers.""" - message = { - "type": "ticker", - "channel": Channel.TICKER.value, - "symbol": symbol, - "data": data, - "timestamp": datetime.utcnow().isoformat() - } - return await self.connections.broadcast_to_channel( - Channel.TICKER, symbol, message - ) - - async def publish_candle( - self, - symbol: str, - timeframe: str, - data: dict, - is_closed: bool = False - ) -> int: - """Publish candle update to subscribers.""" - message = { - "type": "candle", - "channel": Channel.CANDLES.value, - "symbol": symbol, - "timeframe": timeframe, - "data": data, - "is_closed": is_closed, - "timestamp": datetime.utcnow().isoformat() - } - return await self.connections.broadcast_to_channel( - Channel.CANDLES, symbol, message, timeframe - ) - - async def publish_orderbook(self, symbol: str, data: dict) -> int: - """Publish orderbook update to subscribers.""" - message = { - "type": "orderbook", - "channel": Channel.ORDERBOOK.value, - "symbol": symbol, - "data": data, - "timestamp": datetime.utcnow().isoformat() - } - return await self.connections.broadcast_to_channel( - Channel.ORDERBOOK, symbol, message - ) - - async def publish_trade(self, symbol: str, data: dict) -> int: - """Publish trade to subscribers.""" - message = { - "type": "trade", - "channel": Channel.TRADES.value, - "symbol": symbol, - "data": data, - "timestamp": datetime.utcnow().isoformat() - } - return await self.connections.broadcast_to_channel( - Channel.TRADES, symbol, message - ) - - async def publish_signal(self, symbol: str, data: dict) -> int: - """Publish trading signal to subscribers.""" - message = { - "type": "signal", - "channel": Channel.SIGNALS.value, - "symbol": symbol, - "data": data, - "timestamp": datetime.utcnow().isoformat() - } - return await self.connections.broadcast_to_channel( - Channel.SIGNALS, symbol, message - ) diff --git a/apps/data-service/tests/__init__.py b/apps/data-service/tests/__init__.py deleted file mode 100644 index 31f1aee..0000000 --- a/apps/data-service/tests/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Tests for OrbiQuant Data Service -""" diff --git a/apps/data-service/tests/conftest.py b/apps/data-service/tests/conftest.py deleted file mode 100644 index 8a8e91c..0000000 --- a/apps/data-service/tests/conftest.py +++ /dev/null @@ -1,19 +0,0 @@ -""" -Pytest Configuration -OrbiQuant IA Trading Platform - Data Service Tests -""" - -import sys -import os -from pathlib import Path - -# Add src directory to path -src_path = Path(__file__).parent.parent / "src" -sys.path.insert(0, str(src_path)) - -# Set test environment variables -os.environ["POLYGON_API_KEY"] = "test_api_key" -os.environ["DB_HOST"] = "localhost" -os.environ["DB_NAME"] = "test_db" -os.environ["DB_USER"] = "test_user" -os.environ["DB_PASSWORD"] = "test_pass" diff --git a/apps/data-service/tests/test_polygon_client.py b/apps/data-service/tests/test_polygon_client.py deleted file mode 100644 index b2071f7..0000000 --- a/apps/data-service/tests/test_polygon_client.py +++ /dev/null @@ -1,195 +0,0 @@ -""" -Tests for Polygon/Massive Client -OrbiQuant IA Trading Platform -""" - -import pytest -from datetime import datetime, timedelta -from unittest.mock import AsyncMock, MagicMock, patch -import aiohttp - -from providers.polygon_client import ( - PolygonClient, AssetType, Timeframe, OHLCVBar, TickerSnapshot -) - - -class TestPolygonClient: - """Test PolygonClient class.""" - - def test_init_with_api_key(self): - """Test initialization with API key.""" - client = PolygonClient(api_key="test_key") - assert client.api_key == "test_key" - assert client.base_url == PolygonClient.BASE_URL - - def test_init_with_massive_url(self): - """Test initialization with Massive URL.""" - client = PolygonClient(api_key="test_key", use_massive_url=True) - assert client.base_url == PolygonClient.MASSIVE_URL - - def test_init_without_api_key(self): - """Test initialization without API key raises error.""" - with pytest.raises(ValueError, match="API_KEY is required"): - PolygonClient() - - def test_format_symbol_forex(self): - """Test formatting forex symbols.""" - client = PolygonClient(api_key="test") - formatted = client._format_symbol("EURUSD", AssetType.FOREX) - assert formatted == "C:EURUSD" - - def test_format_symbol_crypto(self): - """Test formatting crypto symbols.""" - client = PolygonClient(api_key="test") - formatted = client._format_symbol("BTCUSD", AssetType.CRYPTO) - assert formatted == "X:BTCUSD" - - def test_format_symbol_index(self): - """Test formatting index symbols.""" - client = PolygonClient(api_key="test") - formatted = client._format_symbol("SPX", AssetType.INDEX) - assert formatted == "I:SPX" - - def test_format_symbol_already_formatted(self): - """Test formatting already formatted symbols.""" - client = PolygonClient(api_key="test") - formatted = client._format_symbol("C:EURUSD", AssetType.FOREX) - assert formatted == "C:EURUSD" - - @pytest.mark.asyncio - async def test_rate_limit_wait(self): - """Test rate limiting.""" - client = PolygonClient(api_key="test", rate_limit_per_min=2) - - # First request should not wait - await client._rate_limit_wait() - assert client._request_count == 1 - - # Second request should not wait - await client._rate_limit_wait() - assert client._request_count == 2 - - @pytest.mark.asyncio - async def test_context_manager(self): - """Test using client as context manager.""" - async with PolygonClient(api_key="test") as client: - assert client._session is not None - - @pytest.mark.asyncio - async def test_request_with_mock_response(self): - """Test making API request with mock response.""" - client = PolygonClient(api_key="test") - - # Mock aiohttp session - mock_response = AsyncMock() - mock_response.status = 200 - mock_response.json = AsyncMock(return_value={"results": []}) - mock_response.raise_for_status = MagicMock() - - mock_session = AsyncMock() - mock_session.get.return_value.__aenter__.return_value = mock_response - - client._session = mock_session - - result = await client._request("/test") - - assert "results" in result - mock_session.get.assert_called_once() - - @pytest.mark.asyncio - async def test_request_rate_limited(self): - """Test handling rate limit response.""" - client = PolygonClient(api_key="test") - - # Mock rate limit then success - mock_response_429 = AsyncMock() - mock_response_429.status = 429 - mock_response_429.headers = {"Retry-After": "1"} - - mock_response_200 = AsyncMock() - mock_response_200.status = 200 - mock_response_200.json = AsyncMock(return_value={"status": "OK"}) - mock_response_200.raise_for_status = MagicMock() - - mock_session = AsyncMock() - mock_session.get.return_value.__aenter__.side_effect = [ - mock_response_429, - mock_response_200 - ] - - client._session = mock_session - - with patch('asyncio.sleep', new=AsyncMock()): - result = await client._request("/test") - - assert result["status"] == "OK" - - -class TestTimeframe: - """Test Timeframe enum.""" - - def test_timeframe_values(self): - """Test timeframe enum values.""" - assert Timeframe.MINUTE_1.value == ("1", "minute") - assert Timeframe.MINUTE_5.value == ("5", "minute") - assert Timeframe.MINUTE_15.value == ("15", "minute") - assert Timeframe.HOUR_1.value == ("1", "hour") - assert Timeframe.HOUR_4.value == ("4", "hour") - assert Timeframe.DAY_1.value == ("1", "day") - - -class TestAssetType: - """Test AssetType enum.""" - - def test_asset_type_values(self): - """Test asset type enum values.""" - assert AssetType.FOREX.value == "forex" - assert AssetType.CRYPTO.value == "crypto" - assert AssetType.INDEX.value == "index" - assert AssetType.FUTURES.value == "futures" - assert AssetType.STOCK.value == "stock" - - -class TestOHLCVBar: - """Test OHLCVBar dataclass.""" - - def test_ohlcv_bar_creation(self): - """Test creating OHLCV bar.""" - bar = OHLCVBar( - timestamp=datetime.now(), - open=1.10, - high=1.15, - low=1.09, - close=1.12, - volume=1000000, - vwap=1.11, - transactions=1500 - ) - - assert bar.open == 1.10 - assert bar.close == 1.12 - assert bar.volume == 1000000 - - -class TestTickerSnapshot: - """Test TickerSnapshot dataclass.""" - - def test_ticker_snapshot_creation(self): - """Test creating ticker snapshot.""" - snapshot = TickerSnapshot( - symbol="EURUSD", - bid=1.1000, - ask=1.1002, - last_price=1.1001, - timestamp=datetime.now(), - daily_high=1.1050, - daily_low=1.0950 - ) - - assert snapshot.symbol == "EURUSD" - assert snapshot.bid == 1.1000 - assert snapshot.ask == 1.1002 - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/apps/data-service/tests/test_sync_service.py b/apps/data-service/tests/test_sync_service.py deleted file mode 100644 index 2c77d3a..0000000 --- a/apps/data-service/tests/test_sync_service.py +++ /dev/null @@ -1,227 +0,0 @@ -""" -Tests for Data Synchronization Service -OrbiQuant IA Trading Platform -""" - -import pytest -from datetime import datetime, timedelta -from unittest.mock import AsyncMock, MagicMock, patch - -from services.sync_service import DataSyncService, SyncStatus -from providers.polygon_client import AssetType, Timeframe, OHLCVBar - - -@pytest.fixture -def mock_polygon_client(): - """Mock Polygon client.""" - client = MagicMock() - client.get_ticker_details = AsyncMock(return_value={ - "name": "EUR/USD", - "primary_exchange": "FOREX" - }) - return client - - -@pytest.fixture -def mock_db_pool(): - """Mock database pool.""" - pool = MagicMock() - - # Mock connection - conn = MagicMock() - conn.fetchrow = AsyncMock(return_value={"id": 1, "last_ts": None}) - conn.fetchval = AsyncMock(return_value=1) - conn.fetch = AsyncMock(return_value=[]) - conn.execute = AsyncMock() - conn.executemany = AsyncMock() - - # Mock pool.acquire context manager - pool.acquire = MagicMock() - pool.acquire.return_value.__aenter__ = AsyncMock(return_value=conn) - pool.acquire.return_value.__aexit__ = AsyncMock() - - return pool - - -@pytest.fixture -def sync_service(mock_polygon_client, mock_db_pool): - """Create DataSyncService instance.""" - return DataSyncService( - polygon_client=mock_polygon_client, - db_pool=mock_db_pool, - batch_size=100 - ) - - -class TestDataSyncService: - """Test DataSyncService class.""" - - @pytest.mark.asyncio - async def test_get_or_create_ticker_existing(self, sync_service, mock_db_pool): - """Test getting existing ticker.""" - # Mock existing ticker - conn = await mock_db_pool.acquire().__aenter__() - conn.fetchrow.return_value = {"id": 123} - - ticker_id = await sync_service.get_or_create_ticker("EURUSD", AssetType.FOREX) - - assert ticker_id == 123 - conn.fetchrow.assert_called_once() - - @pytest.mark.asyncio - async def test_get_or_create_ticker_new(self, sync_service, mock_db_pool): - """Test creating new ticker.""" - # Mock no existing ticker, then return new ID - conn = await mock_db_pool.acquire().__aenter__() - conn.fetchrow.return_value = None - conn.fetchval.return_value = 456 - - ticker_id = await sync_service.get_or_create_ticker("GBPUSD", AssetType.FOREX) - - assert ticker_id == 456 - conn.fetchval.assert_called_once() - - @pytest.mark.asyncio - async def test_sync_ticker_data_success(self, sync_service, mock_polygon_client): - """Test successful ticker sync.""" - # Mock data from Polygon - async def mock_aggregates(*args, **kwargs): - bars = [ - OHLCVBar( - timestamp=datetime.now(), - open=1.1000, - high=1.1050, - low=1.0950, - close=1.1025, - volume=1000000, - vwap=1.1012, - transactions=1500 - ) - ] - for bar in bars: - yield bar - - mock_polygon_client.get_aggregates = mock_aggregates - - result = await sync_service.sync_ticker_data( - symbol="EURUSD", - asset_type=AssetType.FOREX, - timeframe=Timeframe.MINUTE_5, - backfill_days=1 - ) - - assert result["status"] == SyncStatus.SUCCESS - assert result["symbol"] == "EURUSD" - assert result["rows_inserted"] >= 0 - - @pytest.mark.asyncio - async def test_sync_ticker_data_no_ticker(self, sync_service, mock_db_pool): - """Test sync when ticker creation fails.""" - # Mock ticker creation failure - conn = await mock_db_pool.acquire().__aenter__() - conn.fetchrow.return_value = None - conn.fetchval.return_value = None - - result = await sync_service.sync_ticker_data( - symbol="INVALID", - asset_type=AssetType.FOREX, - backfill_days=1 - ) - - assert result["status"] == SyncStatus.FAILED - assert "Failed to get/create ticker" in result["error"] - - @pytest.mark.asyncio - async def test_insert_bars(self, sync_service): - """Test inserting bars.""" - bars = [ - (1, datetime.now(), 1.1, 1.15, 1.09, 1.12, 1000, 1.11, 100, 1234567890) - ] - - inserted = await sync_service._insert_bars("ohlcv_5min", bars) - - assert inserted == 1 - - @pytest.mark.asyncio - async def test_get_supported_symbols(self, sync_service): - """Test getting supported symbols.""" - symbols = await sync_service.get_supported_symbols() - - assert len(symbols) > 0 - assert all("symbol" in s for s in symbols) - assert all("asset_type" in s for s in symbols) - - @pytest.mark.asyncio - async def test_get_supported_symbols_filtered(self, sync_service): - """Test getting supported symbols with filter.""" - forex_symbols = await sync_service.get_supported_symbols( - asset_type=AssetType.FOREX - ) - - assert len(forex_symbols) > 0 - assert all(s["asset_type"] == "forex" for s in forex_symbols) - - @pytest.mark.asyncio - async def test_get_sync_status(self, sync_service, mock_db_pool): - """Test getting sync status.""" - # Mock status data - conn = await mock_db_pool.acquire().__aenter__() - conn.fetch.return_value = [ - { - "symbol": "EURUSD", - "asset_type": "forex", - "timeframe": "5min", - "last_sync_timestamp": datetime.now(), - "last_sync_rows": 100, - "sync_status": "success", - "error_message": None, - "updated_at": datetime.now() - } - ] - - status = await sync_service.get_sync_status() - - assert len(status) == 1 - assert status[0]["symbol"] == "EURUSD" - - @pytest.mark.asyncio - async def test_sync_all_active_tickers(self, sync_service, mock_db_pool, mock_polygon_client): - """Test syncing all active tickers.""" - # Mock active tickers - conn = await mock_db_pool.acquire().__aenter__() - conn.fetch.return_value = [ - {"id": 1, "symbol": "EURUSD", "asset_type": "forex"}, - {"id": 2, "symbol": "GBPUSD", "asset_type": "forex"} - ] - - # Mock empty aggregates - async def mock_aggregates(*args, **kwargs): - return - yield # Make it a generator - - mock_polygon_client.get_aggregates = mock_aggregates - - result = await sync_service.sync_all_active_tickers( - timeframe=Timeframe.MINUTE_5, - backfill_days=1 - ) - - assert "total_tickers" in result - assert "successful" in result - assert "total_rows_inserted" in result - - -class TestSyncStatus: - """Test SyncStatus enum.""" - - def test_sync_status_values(self): - """Test SyncStatus enum values.""" - assert SyncStatus.PENDING == "pending" - assert SyncStatus.IN_PROGRESS == "in_progress" - assert SyncStatus.SUCCESS == "success" - assert SyncStatus.FAILED == "failed" - assert SyncStatus.PARTIAL == "partial" - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/apps/database/DIRECTIVA-POLITICA-CARGA-LIMPIA.md b/apps/database/DIRECTIVA-POLITICA-CARGA-LIMPIA.md deleted file mode 100644 index cb090ad..0000000 --- a/apps/database/DIRECTIVA-POLITICA-CARGA-LIMPIA.md +++ /dev/null @@ -1,259 +0,0 @@ -# DIRECTIVA: Politica de Carga Limpia (DDL-First) - -**ID:** DIR-DB-001 -**Version:** 1.0.0 -**Fecha:** 2025-12-06 -**Estado:** ACTIVA -**Aplica a:** Todos los agentes que trabajen con base de datos - ---- - -## OBJETIVO - -Establecer una politica clara y obligatoria para la gestion del esquema de base de datos del proyecto Trading Platform (OrbiQuant IA), garantizando que la base de datos pueda ser creada o recreada completamente desde archivos DDL sin dependencia de migraciones incrementales. - ---- - -## PRINCIPIO FUNDAMENTAL - -> **La base de datos SIEMPRE debe poder ser creada desde cero ejecutando unicamente los archivos DDL.** - -Esto significa: -- NO migraciones incrementales -- NO archivos de "fix" o "patch" -- NO scripts de correccion -- NO ALTER TABLE en archivos separados - ---- - -## REGLAS OBLIGATORIAS - -### 1. Estructura de Archivos - -``` -apps/database/ -├── ddl/ -│ └── schemas/ -│ ├── {schema}/ -│ │ ├── 00-enums.sql # Tipos enumerados -│ │ ├── tables/ -│ │ │ ├── 01-{tabla}.sql # Una tabla por archivo -│ │ │ ├── 02-{tabla}.sql -│ │ │ └── ... -│ │ ├── functions/ -│ │ │ ├── 01-{funcion}.sql -│ │ │ └── ... -│ │ ├── triggers/ -│ │ │ └── ... -│ │ └── views/ -│ │ └── ... -├── seeds/ -│ ├── prod/ # Datos de produccion -│ └── dev/ # Datos de desarrollo -└── scripts/ - ├── create-database.sh # Crear BD - └── drop-and-recreate-database.sh # Recrear BD -``` - -### 2. Nomenclatura de Archivos - -| Tipo | Patron | Ejemplo | -|------|--------|---------| -| Enums | `00-enums.sql` | `00-enums.sql` | -| Tablas | `NN-{nombre}.sql` | `01-users.sql`, `02-profiles.sql` | -| Funciones | `NN-{nombre}.sql` | `01-update_updated_at.sql` | -| Triggers | `NN-{nombre}.sql` | `01-audit_trigger.sql` | -| Views | `NN-{nombre}.sql` | `01-user_summary.sql` | - -El numero `NN` indica el orden de ejecucion dentro de cada carpeta. - -### 3. Modificaciones al Schema - -**CORRECTO:** -```sql --- Editar directamente el archivo DDL original --- apps/database/ddl/schemas/auth/tables/01-users.sql - -CREATE TABLE IF NOT EXISTS auth.users ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - email VARCHAR(255) UNIQUE NOT NULL, - -- Agregar nuevas columnas aqui - phone VARCHAR(20), -- <-- Nueva columna - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); -``` - -**INCORRECTO:** -```sql --- NO crear archivos de migracion --- migrations/20251206_add_phone_to_users.sql <-- PROHIBIDO - -ALTER TABLE auth.users ADD COLUMN phone VARCHAR(20); -``` - -### 4. Cuando Necesitas Cambiar el Schema - -1. **Edita el archivo DDL original** de la tabla/funcion/trigger -2. **Ejecuta recreacion** en tu ambiente de desarrollo: - ```bash - ./scripts/drop-and-recreate-database.sh - ``` -3. **Verifica** que todo funcione correctamente -4. **Commit** los cambios al DDL - -### 5. Prohibiciones Explicitas - -| Accion | Permitido | Razon | -|--------|-----------|-------| -| Crear archivo `migrations/*.sql` | NO | Rompe carga limpia | -| Crear archivo `fix-*.sql` | NO | Rompe carga limpia | -| Crear archivo `patch-*.sql` | NO | Rompe carga limpia | -| Crear archivo `alter-*.sql` | NO | Rompe carga limpia | -| Usar `ALTER TABLE` en archivo separado | NO | Debe estar en DDL original | -| Modificar DDL original directamente | SI | Es la forma correcta | - ---- - -## ESTANDARES TECNICOS - -### Tipos de Datos - -| Uso | Tipo Correcto | Tipo Incorrecto | -|-----|---------------|-----------------| -| Timestamps | `TIMESTAMPTZ` | `TIMESTAMP` | -| UUIDs | `gen_random_uuid()` | `uuid_generate_v4()` | -| Moneda | `DECIMAL(20,8)` | `FLOAT`, `DOUBLE` | -| Texto variable | `VARCHAR(n)` | `CHAR(n)` para texto variable | - -### Convenciones SQL - -```sql --- Nombres en snake_case -CREATE TABLE auth.user_profiles ( -- Correcto -CREATE TABLE auth.UserProfiles ( -- Incorrecto - --- Siempre incluir schema -CREATE TABLE auth.users ( -- Correcto -CREATE TABLE users ( -- Incorrecto (usa public) - --- IF NOT EXISTS en CREATE -CREATE TABLE IF NOT EXISTS auth.users ( -CREATE TYPE IF NOT EXISTS auth.user_status AS ENUM ( - --- Indices con prefijo descriptivo -CREATE INDEX idx_users_email ON auth.users(email); -CREATE INDEX idx_users_created ON auth.users(created_at DESC); -``` - -### Foreign Keys - -```sql --- Siempre referenciar con schema completo -user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - --- Nunca asumir schema -user_id UUID NOT NULL REFERENCES users(id), -- INCORRECTO -``` - ---- - -## ORDEN DE CARGA - -El script `create-database.sh` ejecuta en este orden: - -1. **Extensiones** - - uuid-ossp - - pgcrypto - - pg_trgm - - btree_gin - - vector (si disponible) - -2. **Schemas** (en orden) - - auth - - education - - financial - - trading - - investment - - ml - - llm - - audit - -3. **Por cada schema:** - - 00-enums.sql (si existe) - - tables/*.sql (orden numerico) - - functions/*.sql (orden numerico) - - triggers/*.sql (orden numerico) - - views/*.sql (orden numerico) - -4. **Seeds** - - prod/ o dev/ segun ambiente - ---- - -## VALIDACION - -### Pre-commit Checklist - -Antes de hacer commit de cambios a DDL: - -- [ ] No existen archivos `migrations/`, `fix-*`, `patch-*`, `alter-*` -- [ ] Todos los cambios estan en archivos DDL originales -- [ ] Se puede ejecutar `drop-and-recreate-database.sh` sin errores -- [ ] Todas las FKs usan schema completo (`auth.users`, no `users`) -- [ ] Todos los timestamps son `TIMESTAMPTZ` -- [ ] Todos los UUIDs usan `gen_random_uuid()` - -### Script de Validacion - -```bash -# Verificar que no hay archivos prohibidos -find apps/database -name "fix-*.sql" -o -name "patch-*.sql" -o -name "alter-*.sql" -# Debe retornar vacio - -# Verificar que no hay carpeta migrations con contenido nuevo -ls apps/database/migrations/ -# Solo debe existir si hay migraciones legacy (a eliminar) -``` - ---- - -## EXCEPCIONES - -### Unica Excepcion: Datos de Produccion - -Si hay datos de produccion que NO pueden perderse: - -1. **Exportar datos** antes de recrear -2. **Recrear schema** con DDL limpio -3. **Importar datos** desde backup - -Esto NO es una migracion, es un proceso de backup/restore. - ---- - -## CONSECUENCIAS DE VIOLAR ESTA DIRECTIVA - -1. **Build fallara** - CI/CD rechazara archivos prohibidos -2. **PR sera rechazado** - Code review detectara violaciones -3. **Deuda tecnica** - Se acumularan inconsistencias - ---- - -## REFERENCIAS - -- [_MAP.md - Database Schemas](../apps/database/schemas/_MAP.md) -- [DECISIONES-ARQUITECTONICAS.md](../docs/99-analisis/DECISIONES-ARQUITECTONICAS.md) -- [create-database.sh](../apps/database/scripts/create-database.sh) - ---- - -## HISTORIAL DE CAMBIOS - -| Version | Fecha | Cambio | -|---------|-------|--------| -| 1.0.0 | 2025-12-06 | Version inicial | - ---- - -*Directiva establecida por Requirements-Analyst Agent* -*OrbiQuant IA Trading Platform* diff --git a/apps/database/ddl/00-extensions.sql b/apps/database/ddl/00-extensions.sql deleted file mode 100644 index 6ed9a68..0000000 --- a/apps/database/ddl/00-extensions.sql +++ /dev/null @@ -1,26 +0,0 @@ --- ============================================================================ --- OrbiQuant IA - Trading Platform --- File: 00-extensions.sql --- Description: PostgreSQL extensions required globally --- ============================================================================ - --- UUID generation extension -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; - --- Cryptographic functions for password hashing and token generation -CREATE EXTENSION IF NOT EXISTS "pgcrypto"; - --- Network address types and functions -CREATE EXTENSION IF NOT EXISTS "citext"; - --- Full text search -CREATE EXTENSION IF NOT EXISTS "unaccent"; - --- Trigram similarity for fuzzy text matching -CREATE EXTENSION IF NOT EXISTS "pg_trgm"; - -COMMENT ON EXTENSION "uuid-ossp" IS 'UUID generation functions'; -COMMENT ON EXTENSION "pgcrypto" IS 'Cryptographic functions for secure password and token handling'; -COMMENT ON EXTENSION "citext" IS 'Case-insensitive text type for email addresses'; -COMMENT ON EXTENSION "unaccent" IS 'Text search dictionary that removes accents'; -COMMENT ON EXTENSION "pg_trgm" IS 'Trigram matching for similarity searches'; diff --git a/apps/database/ddl/01-schemas.sql b/apps/database/ddl/01-schemas.sql deleted file mode 100644 index 7fc1ea7..0000000 --- a/apps/database/ddl/01-schemas.sql +++ /dev/null @@ -1,37 +0,0 @@ --- ============================================================================ --- OrbiQuant IA - Trading Platform --- File: 01-schemas.sql --- Description: Database schemas creation --- ============================================================================ - --- Authentication and User Management -CREATE SCHEMA IF NOT EXISTS auth; -COMMENT ON SCHEMA auth IS 'Authentication, authorization, and user management'; - --- Education and Learning -CREATE SCHEMA IF NOT EXISTS education; -COMMENT ON SCHEMA education IS 'Educational content, courses, and learning progress'; - --- Trading Operations -CREATE SCHEMA IF NOT EXISTS trading; -COMMENT ON SCHEMA trading IS 'Trading bots, orders, positions, and market data'; - --- Investment Management -CREATE SCHEMA IF NOT EXISTS investment; -COMMENT ON SCHEMA investment IS 'Investment products, accounts, and transactions'; - --- Financial Operations -CREATE SCHEMA IF NOT EXISTS financial; -COMMENT ON SCHEMA financial IS 'Wallets, payments, subscriptions, and financial transactions'; - --- Machine Learning -CREATE SCHEMA IF NOT EXISTS ml; -COMMENT ON SCHEMA ml IS 'ML models, predictions, and feature store'; - --- Large Language Models -CREATE SCHEMA IF NOT EXISTS llm; -COMMENT ON SCHEMA llm IS 'LLM conversations, messages, and user preferences'; - --- Audit and Compliance -CREATE SCHEMA IF NOT EXISTS audit; -COMMENT ON SCHEMA audit IS 'Audit logs, security events, and compliance tracking'; diff --git a/apps/database/ddl/schemas/audit/00-enums.sql b/apps/database/ddl/schemas/audit/00-enums.sql deleted file mode 100644 index 5356735..0000000 --- a/apps/database/ddl/schemas/audit/00-enums.sql +++ /dev/null @@ -1,63 +0,0 @@ --- ============================================================================ --- AUDIT SCHEMA - ENUMs --- ============================================================================ --- OrbiQuant IA Trading Platform --- Schema: audit --- Propósito: Tipos enumerados para auditoría y logging --- ============================================================================ - --- Tipo de evento de auditoría -CREATE TYPE audit.audit_event_type AS ENUM ( - 'create', - 'read', - 'update', - 'delete', - 'login', - 'logout', - 'permission_change', - 'config_change', - 'export', - 'import' -); - --- Severidad del evento -CREATE TYPE audit.event_severity AS ENUM ( - 'debug', - 'info', - 'warning', - 'error', - 'critical' -); - --- Categoría del evento de seguridad -CREATE TYPE audit.security_event_category AS ENUM ( - 'authentication', - 'authorization', - 'data_access', - 'configuration', - 'suspicious_activity', - 'compliance' -); - --- Estado del evento -CREATE TYPE audit.event_status AS ENUM ( - 'success', - 'failure', - 'blocked', - 'pending_review' -); - --- Tipo de recurso auditado -CREATE TYPE audit.resource_type AS ENUM ( - 'user', - 'account', - 'transaction', - 'order', - 'position', - 'bot', - 'subscription', - 'payment', - 'course', - 'model', - 'system_config' -); diff --git a/apps/database/ddl/schemas/audit/tables/01-audit_logs.sql b/apps/database/ddl/schemas/audit/tables/01-audit_logs.sql deleted file mode 100644 index 545eda6..0000000 --- a/apps/database/ddl/schemas/audit/tables/01-audit_logs.sql +++ /dev/null @@ -1,54 +0,0 @@ --- ============================================================================ --- AUDIT SCHEMA - Tabla: audit_logs --- ============================================================================ --- Tabla principal de auditoría general --- ============================================================================ - -CREATE TABLE IF NOT EXISTS audit.audit_logs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Información del evento - event_type audit.audit_event_type NOT NULL, - event_status audit.event_status NOT NULL DEFAULT 'success', - severity audit.event_severity NOT NULL DEFAULT 'info', - - -- Quién realizó la acción - user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, - session_id UUID, - ip_address INET, - user_agent TEXT, - - -- Qué se modificó - resource_type audit.resource_type NOT NULL, - resource_id UUID, - resource_name VARCHAR(255), - - -- Detalles del cambio - action VARCHAR(100) NOT NULL, - description TEXT, - old_values JSONB, - new_values JSONB, - metadata JSONB DEFAULT '{}', - - -- Contexto - request_id UUID, - correlation_id UUID, - service_name VARCHAR(50), - - -- Timestamps - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Índices para consultas frecuentes -CREATE INDEX idx_audit_logs_user_id ON audit.audit_logs(user_id); -CREATE INDEX idx_audit_logs_resource ON audit.audit_logs(resource_type, resource_id); -CREATE INDEX idx_audit_logs_event_type ON audit.audit_logs(event_type); -CREATE INDEX idx_audit_logs_created_at ON audit.audit_logs(created_at DESC); -CREATE INDEX idx_audit_logs_severity ON audit.audit_logs(severity) WHERE severity IN ('error', 'critical'); -CREATE INDEX idx_audit_logs_correlation ON audit.audit_logs(correlation_id) WHERE correlation_id IS NOT NULL; - --- Índice GIN para búsqueda en JSONB -CREATE INDEX idx_audit_logs_metadata ON audit.audit_logs USING GIN (metadata); - -COMMENT ON TABLE audit.audit_logs IS 'Log general de auditoría para todas las acciones del sistema'; -COMMENT ON COLUMN audit.audit_logs.correlation_id IS 'ID para correlacionar eventos relacionados en una misma operación'; diff --git a/apps/database/ddl/schemas/audit/tables/02-security_events.sql b/apps/database/ddl/schemas/audit/tables/02-security_events.sql deleted file mode 100644 index 7701dc5..0000000 --- a/apps/database/ddl/schemas/audit/tables/02-security_events.sql +++ /dev/null @@ -1,57 +0,0 @@ --- ============================================================================ --- AUDIT SCHEMA - Tabla: security_events --- ============================================================================ --- Eventos de seguridad específicos --- ============================================================================ - -CREATE TABLE IF NOT EXISTS audit.security_events ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Categorización - category audit.security_event_category NOT NULL, - severity audit.event_severity NOT NULL, - event_status audit.event_status NOT NULL DEFAULT 'success', - - -- Actor - user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, - ip_address INET NOT NULL, - user_agent TEXT, - geo_location JSONB, - - -- Detalles del evento - event_code VARCHAR(50) NOT NULL, - event_name VARCHAR(255) NOT NULL, - description TEXT, - - -- Contexto técnico - request_path VARCHAR(500), - request_method VARCHAR(10), - response_code INTEGER, - - -- Datos adicionales - risk_score DECIMAL(3, 2), - is_blocked BOOLEAN DEFAULT FALSE, - block_reason TEXT, - requires_review BOOLEAN DEFAULT FALSE, - reviewed_by UUID REFERENCES auth.users(id), - reviewed_at TIMESTAMPTZ, - review_notes TEXT, - - -- Metadata - raw_data JSONB DEFAULT '{}', - - -- Timestamps - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Índices -CREATE INDEX idx_security_events_user ON audit.security_events(user_id); -CREATE INDEX idx_security_events_category ON audit.security_events(category); -CREATE INDEX idx_security_events_severity ON audit.security_events(severity); -CREATE INDEX idx_security_events_ip ON audit.security_events(ip_address); -CREATE INDEX idx_security_events_created ON audit.security_events(created_at DESC); -CREATE INDEX idx_security_events_blocked ON audit.security_events(is_blocked) WHERE is_blocked = TRUE; -CREATE INDEX idx_security_events_review ON audit.security_events(requires_review) WHERE requires_review = TRUE; - -COMMENT ON TABLE audit.security_events IS 'Eventos de seguridad para monitoreo y respuesta a incidentes'; -COMMENT ON COLUMN audit.security_events.risk_score IS 'Puntuación de riesgo calculada (0.00-1.00)'; diff --git a/apps/database/ddl/schemas/audit/tables/03-system_events.sql b/apps/database/ddl/schemas/audit/tables/03-system_events.sql deleted file mode 100644 index 03966c7..0000000 --- a/apps/database/ddl/schemas/audit/tables/03-system_events.sql +++ /dev/null @@ -1,47 +0,0 @@ --- ============================================================================ --- AUDIT SCHEMA - Tabla: system_events --- ============================================================================ --- Eventos del sistema (jobs, tareas programadas, errores) --- ============================================================================ - -CREATE TABLE IF NOT EXISTS audit.system_events ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Categorización - event_type VARCHAR(50) NOT NULL, - severity audit.event_severity NOT NULL DEFAULT 'info', - - -- Origen - service_name VARCHAR(100) NOT NULL, - component VARCHAR(100), - environment VARCHAR(20) NOT NULL DEFAULT 'production', - hostname VARCHAR(255), - - -- Detalles - event_name VARCHAR(255) NOT NULL, - message TEXT NOT NULL, - stack_trace TEXT, - - -- Contexto - correlation_id UUID, - job_id VARCHAR(100), - duration_ms INTEGER, - - -- Metadata - metadata JSONB DEFAULT '{}', - tags TEXT[], - - -- Timestamps - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Índices -CREATE INDEX idx_system_events_service ON audit.system_events(service_name); -CREATE INDEX idx_system_events_type ON audit.system_events(event_type); -CREATE INDEX idx_system_events_severity ON audit.system_events(severity); -CREATE INDEX idx_system_events_created ON audit.system_events(created_at DESC); -CREATE INDEX idx_system_events_correlation ON audit.system_events(correlation_id) WHERE correlation_id IS NOT NULL; -CREATE INDEX idx_system_events_job ON audit.system_events(job_id) WHERE job_id IS NOT NULL; -CREATE INDEX idx_system_events_tags ON audit.system_events USING GIN (tags); - -COMMENT ON TABLE audit.system_events IS 'Eventos del sistema para monitoreo de infraestructura y jobs'; diff --git a/apps/database/ddl/schemas/audit/tables/04-trading_audit.sql b/apps/database/ddl/schemas/audit/tables/04-trading_audit.sql deleted file mode 100644 index 2dc245e..0000000 --- a/apps/database/ddl/schemas/audit/tables/04-trading_audit.sql +++ /dev/null @@ -1,57 +0,0 @@ --- ============================================================================ --- AUDIT SCHEMA - Tabla: trading_audit --- ============================================================================ --- Auditoría específica de operaciones de trading --- ============================================================================ - -CREATE TABLE IF NOT EXISTS audit.trading_audit ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Actor - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - bot_id UUID, - - -- Acción - action VARCHAR(50) NOT NULL, - action_status audit.event_status NOT NULL, - - -- Objeto de la acción - order_id UUID, - position_id UUID, - symbol VARCHAR(20) NOT NULL, - - -- Detalles de la operación - side VARCHAR(4) NOT NULL, -- 'buy' o 'sell' - order_type VARCHAR(20), - quantity DECIMAL(20, 8) NOT NULL, - price DECIMAL(20, 8), - executed_price DECIMAL(20, 8), - - -- Resultado - pnl DECIMAL(20, 8), - fees DECIMAL(20, 8), - - -- Contexto - strategy_id UUID, - signal_id UUID, - is_paper_trading BOOLEAN DEFAULT FALSE, - - -- Metadata - execution_time_ms INTEGER, - broker_response JSONB, - metadata JSONB DEFAULT '{}', - - -- Timestamps - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Índices -CREATE INDEX idx_trading_audit_user ON audit.trading_audit(user_id); -CREATE INDEX idx_trading_audit_bot ON audit.trading_audit(bot_id) WHERE bot_id IS NOT NULL; -CREATE INDEX idx_trading_audit_symbol ON audit.trading_audit(symbol); -CREATE INDEX idx_trading_audit_action ON audit.trading_audit(action); -CREATE INDEX idx_trading_audit_created ON audit.trading_audit(created_at DESC); -CREATE INDEX idx_trading_audit_order ON audit.trading_audit(order_id) WHERE order_id IS NOT NULL; -CREATE INDEX idx_trading_audit_position ON audit.trading_audit(position_id) WHERE position_id IS NOT NULL; - -COMMENT ON TABLE audit.trading_audit IS 'Auditoría detallada de todas las operaciones de trading'; diff --git a/apps/database/ddl/schemas/audit/tables/05-api_request_logs.sql b/apps/database/ddl/schemas/audit/tables/05-api_request_logs.sql deleted file mode 100644 index 3b83ff5..0000000 --- a/apps/database/ddl/schemas/audit/tables/05-api_request_logs.sql +++ /dev/null @@ -1,49 +0,0 @@ --- ============================================================================ --- AUDIT SCHEMA - Tabla: api_request_logs --- ============================================================================ --- Log de requests a la API (para debugging y análisis) --- ============================================================================ - -CREATE TABLE IF NOT EXISTS audit.api_request_logs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Request - request_id UUID NOT NULL, - method VARCHAR(10) NOT NULL, - path VARCHAR(500) NOT NULL, - query_params JSONB, - headers JSONB, - body_size INTEGER, - - -- Actor - user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, - api_key_id UUID, - ip_address INET NOT NULL, - user_agent TEXT, - - -- Response - status_code INTEGER NOT NULL, - response_size INTEGER, - response_time_ms INTEGER NOT NULL, - - -- Contexto - service_name VARCHAR(50), - version VARCHAR(20), - error_code VARCHAR(50), - error_message TEXT, - - -- Timestamps - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Índices optimizados para consultas de análisis -CREATE INDEX idx_api_logs_user ON audit.api_request_logs(user_id); -CREATE INDEX idx_api_logs_path ON audit.api_request_logs(path); -CREATE INDEX idx_api_logs_status ON audit.api_request_logs(status_code); -CREATE INDEX idx_api_logs_created ON audit.api_request_logs(created_at DESC); -CREATE INDEX idx_api_logs_ip ON audit.api_request_logs(ip_address); -CREATE INDEX idx_api_logs_slow ON audit.api_request_logs(response_time_ms) WHERE response_time_ms > 1000; -CREATE INDEX idx_api_logs_errors ON audit.api_request_logs(status_code) WHERE status_code >= 400; - -COMMENT ON TABLE audit.api_request_logs IS 'Log de requests HTTP para análisis y debugging'; -COMMENT ON COLUMN audit.api_request_logs.body_size IS 'Tamaño del body en bytes (no se guarda contenido por seguridad)'; diff --git a/apps/database/ddl/schemas/audit/tables/06-data_access_logs.sql b/apps/database/ddl/schemas/audit/tables/06-data_access_logs.sql deleted file mode 100644 index dce4435..0000000 --- a/apps/database/ddl/schemas/audit/tables/06-data_access_logs.sql +++ /dev/null @@ -1,45 +0,0 @@ --- ============================================================================ --- AUDIT SCHEMA - Tabla: data_access_logs --- ============================================================================ --- Log de acceso a datos sensibles (cumplimiento regulatorio) --- ============================================================================ - -CREATE TABLE IF NOT EXISTS audit.data_access_logs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Quién accedió - accessor_user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - accessor_role VARCHAR(50) NOT NULL, - - -- A qué datos se accedió - target_user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, - data_category VARCHAR(50) NOT NULL, -- 'pii', 'financial', 'health', 'credentials' - data_fields TEXT[], -- campos específicos accedidos - - -- Cómo se accedió - access_type VARCHAR(20) NOT NULL, -- 'view', 'export', 'modify', 'delete' - access_reason TEXT, - - -- Contexto - request_id UUID, - ip_address INET, - user_agent TEXT, - - -- Compliance - consent_verified BOOLEAN DEFAULT FALSE, - legal_basis VARCHAR(100), - retention_days INTEGER, - - -- Timestamps - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Índices -CREATE INDEX idx_data_access_accessor ON audit.data_access_logs(accessor_user_id); -CREATE INDEX idx_data_access_target ON audit.data_access_logs(target_user_id); -CREATE INDEX idx_data_access_category ON audit.data_access_logs(data_category); -CREATE INDEX idx_data_access_type ON audit.data_access_logs(access_type); -CREATE INDEX idx_data_access_created ON audit.data_access_logs(created_at DESC); - -COMMENT ON TABLE audit.data_access_logs IS 'Registro de acceso a datos sensibles para cumplimiento GDPR/CCPA'; -COMMENT ON COLUMN audit.data_access_logs.legal_basis IS 'Base legal para el acceso (consentimiento, contrato, obligación legal, etc.)'; diff --git a/apps/database/ddl/schemas/audit/tables/07-compliance_logs.sql b/apps/database/ddl/schemas/audit/tables/07-compliance_logs.sql deleted file mode 100644 index ff6cced..0000000 --- a/apps/database/ddl/schemas/audit/tables/07-compliance_logs.sql +++ /dev/null @@ -1,52 +0,0 @@ --- ============================================================================ --- AUDIT SCHEMA - Tabla: compliance_logs --- ============================================================================ --- Log de eventos de cumplimiento regulatorio --- ============================================================================ - -CREATE TABLE IF NOT EXISTS audit.compliance_logs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Regulación - regulation VARCHAR(50) NOT NULL, -- 'GDPR', 'CCPA', 'SOX', 'PCI-DSS', 'MiFID' - requirement VARCHAR(100) NOT NULL, - - -- Evento - event_type VARCHAR(50) NOT NULL, - event_description TEXT NOT NULL, - - -- Actor - user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, - system_initiated BOOLEAN DEFAULT FALSE, - - -- Estado - compliance_status VARCHAR(20) NOT NULL, -- 'compliant', 'non_compliant', 'remediation' - risk_level VARCHAR(20), -- 'low', 'medium', 'high', 'critical' - - -- Detalles - evidence JSONB, - remediation_required BOOLEAN DEFAULT FALSE, - remediation_deadline TIMESTAMPTZ, - remediation_notes TEXT, - - -- Revisión - reviewed_by UUID REFERENCES auth.users(id), - reviewed_at TIMESTAMPTZ, - - -- Metadata - metadata JSONB DEFAULT '{}', - - -- Timestamps - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Índices -CREATE INDEX idx_compliance_regulation ON audit.compliance_logs(regulation); -CREATE INDEX idx_compliance_status ON audit.compliance_logs(compliance_status); -CREATE INDEX idx_compliance_risk ON audit.compliance_logs(risk_level); -CREATE INDEX idx_compliance_created ON audit.compliance_logs(created_at DESC); -CREATE INDEX idx_compliance_remediation ON audit.compliance_logs(remediation_required) - WHERE remediation_required = TRUE; - -COMMENT ON TABLE audit.compliance_logs IS 'Registro de cumplimiento regulatorio para auditorías'; diff --git a/apps/database/ddl/schemas/auth/00-extensions.sql b/apps/database/ddl/schemas/auth/00-extensions.sql deleted file mode 100644 index 2ccf481..0000000 --- a/apps/database/ddl/schemas/auth/00-extensions.sql +++ /dev/null @@ -1,19 +0,0 @@ --- ============================================================================ --- OrbiQuant IA - Trading Platform --- Schema: auth --- File: 00-extensions.sql --- Description: PostgreSQL extensions required for authentication schema --- ============================================================================ - --- UUID generation extension -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; - --- Cryptographic functions for password hashing and token generation -CREATE EXTENSION IF NOT EXISTS "pgcrypto"; - --- Network address types and functions -CREATE EXTENSION IF NOT EXISTS "citext"; - -COMMENT ON EXTENSION "uuid-ossp" IS 'UUID generation functions'; -COMMENT ON EXTENSION "pgcrypto" IS 'Cryptographic functions for secure password and token handling'; -COMMENT ON EXTENSION "citext" IS 'Case-insensitive text type for email addresses'; diff --git a/apps/database/ddl/schemas/auth/01-enums.sql b/apps/database/ddl/schemas/auth/01-enums.sql deleted file mode 100644 index 3fd7991..0000000 --- a/apps/database/ddl/schemas/auth/01-enums.sql +++ /dev/null @@ -1,80 +0,0 @@ --- ============================================================================ --- OrbiQuant IA - Trading Platform --- Schema: auth --- File: 01-enums.sql --- Description: Enumerated types for authentication and authorization --- ============================================================================ - --- User account status -CREATE TYPE auth.user_status AS ENUM ( - 'pending_verification', - 'active', - 'suspended', - 'deactivated', - 'banned' -); - -COMMENT ON TYPE auth.user_status IS 'User account status lifecycle states'; - --- User role for RBAC -CREATE TYPE auth.user_role AS ENUM ( - 'user', - 'trader', - 'analyst', - 'admin', - 'super_admin' -); - -COMMENT ON TYPE auth.user_role IS 'Role-based access control roles'; - --- OAuth provider types -CREATE TYPE auth.oauth_provider AS ENUM ( - 'google', - 'facebook', - 'apple', - 'github', - 'microsoft', - 'twitter' -); - -COMMENT ON TYPE auth.oauth_provider IS 'Supported OAuth 2.0 providers'; - --- Phone verification channel -CREATE TYPE auth.phone_channel AS ENUM ( - 'sms', - 'whatsapp' -); - -COMMENT ON TYPE auth.phone_channel IS 'Phone verification delivery channels'; - --- Authentication event types for logging -CREATE TYPE auth.auth_event_type AS ENUM ( - 'login', - 'logout', - 'register', - 'password_change', - 'password_reset_request', - 'password_reset_complete', - 'email_verification', - 'phone_verification', - 'mfa_enabled', - 'mfa_disabled', - 'session_expired', - 'account_suspended', - 'account_reactivated', - 'failed_login', - 'oauth_linked', - 'oauth_unlinked' -); - -COMMENT ON TYPE auth.auth_event_type IS 'Types of authentication events for audit logging'; - --- MFA method types -CREATE TYPE auth.mfa_method AS ENUM ( - 'none', - 'totp', - 'sms', - 'email' -); - -COMMENT ON TYPE auth.mfa_method IS 'Multi-factor authentication methods'; diff --git a/apps/database/ddl/schemas/auth/functions/01-update_updated_at.sql b/apps/database/ddl/schemas/auth/functions/01-update_updated_at.sql deleted file mode 100644 index 3cd3d22..0000000 --- a/apps/database/ddl/schemas/auth/functions/01-update_updated_at.sql +++ /dev/null @@ -1,48 +0,0 @@ --- ============================================================================ --- OrbiQuant IA - Trading Platform --- Schema: auth --- File: functions/01-update_updated_at.sql --- Description: Trigger function to automatically update updated_at timestamp --- ============================================================================ - -CREATE OR REPLACE FUNCTION auth.update_updated_at() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = NOW(); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION auth.update_updated_at() IS 'Trigger function to automatically update updated_at column on row modification'; - --- Apply trigger to all tables with updated_at column - --- Users table -CREATE TRIGGER trigger_update_users_updated_at - BEFORE UPDATE ON auth.users - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at(); - --- User profiles table -CREATE TRIGGER trigger_update_user_profiles_updated_at - BEFORE UPDATE ON auth.user_profiles - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at(); - --- OAuth accounts table -CREATE TRIGGER trigger_update_oauth_accounts_updated_at - BEFORE UPDATE ON auth.oauth_accounts - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at(); - --- Sessions table -CREATE TRIGGER trigger_update_sessions_updated_at - BEFORE UPDATE ON auth.sessions - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at(); - --- Rate limiting config table -CREATE TRIGGER trigger_update_rate_limiting_config_updated_at - BEFORE UPDATE ON auth.rate_limiting_config - FOR EACH ROW - EXECUTE FUNCTION auth.update_updated_at(); diff --git a/apps/database/ddl/schemas/auth/functions/02-log_auth_event.sql b/apps/database/ddl/schemas/auth/functions/02-log_auth_event.sql deleted file mode 100644 index 6b91882..0000000 --- a/apps/database/ddl/schemas/auth/functions/02-log_auth_event.sql +++ /dev/null @@ -1,75 +0,0 @@ --- ============================================================================ --- OrbiQuant IA - Trading Platform --- Schema: auth --- File: functions/02-log_auth_event.sql --- Description: Function to log authentication events to auth_logs table --- ============================================================================ - -CREATE OR REPLACE FUNCTION auth.log_auth_event( - p_event_type auth.auth_event_type, - p_user_id UUID DEFAULT NULL, - p_email CITEXT DEFAULT NULL, - p_ip_address INET DEFAULT NULL, - p_user_agent TEXT DEFAULT NULL, - p_session_id UUID DEFAULT NULL, - p_success BOOLEAN DEFAULT true, - p_failure_reason VARCHAR(255) DEFAULT NULL, - p_metadata JSONB DEFAULT '{}'::jsonb -) -RETURNS UUID AS $$ -DECLARE - v_log_id UUID; -BEGIN - INSERT INTO auth.auth_logs ( - event_type, - user_id, - email, - ip_address, - user_agent, - session_id, - success, - failure_reason, - metadata, - created_at - ) VALUES ( - p_event_type, - p_user_id, - p_email, - p_ip_address, - p_user_agent, - p_session_id, - p_success, - p_failure_reason, - p_metadata, - NOW() - ) - RETURNING id INTO v_log_id; - - RETURN v_log_id; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION auth.log_auth_event( - auth.auth_event_type, - UUID, - CITEXT, - INET, - TEXT, - UUID, - BOOLEAN, - VARCHAR, - JSONB -) IS 'Logs authentication events to the auth_logs table with optional metadata'; - --- Example usage: --- SELECT auth.log_auth_event( --- 'login'::auth.auth_event_type, --- '123e4567-e89b-12d3-a456-426614174000'::UUID, --- 'user@example.com'::CITEXT, --- '192.168.1.1'::INET, --- 'Mozilla/5.0...', --- '123e4567-e89b-12d3-a456-426614174001'::UUID, --- true, --- NULL, --- '{"device": "mobile"}'::JSONB --- ); diff --git a/apps/database/ddl/schemas/auth/functions/03-cleanup_expired_sessions.sql b/apps/database/ddl/schemas/auth/functions/03-cleanup_expired_sessions.sql deleted file mode 100644 index f8574b0..0000000 --- a/apps/database/ddl/schemas/auth/functions/03-cleanup_expired_sessions.sql +++ /dev/null @@ -1,58 +0,0 @@ --- ============================================================================ --- OrbiQuant IA - Trading Platform --- Schema: auth --- File: functions/03-cleanup_expired_sessions.sql --- Description: Function to cleanup expired and inactive sessions --- ============================================================================ - -CREATE OR REPLACE FUNCTION auth.cleanup_expired_sessions( - p_batch_size INTEGER DEFAULT 1000 -) -RETURNS TABLE( - deleted_count INTEGER, - execution_time_ms NUMERIC -) AS $$ -DECLARE - v_start_time TIMESTAMPTZ; - v_deleted_count INTEGER; -BEGIN - v_start_time := clock_timestamp(); - - -- Delete expired sessions - WITH deleted AS ( - DELETE FROM auth.sessions - WHERE ( - -- Expired sessions - expires_at < NOW() OR - -- Inactive sessions older than 30 days - (is_active = false AND invalidated_at < NOW() - INTERVAL '30 days') - ) - AND id IN ( - SELECT id FROM auth.sessions - WHERE ( - expires_at < NOW() OR - (is_active = false AND invalidated_at < NOW() - INTERVAL '30 days') - ) - LIMIT p_batch_size - ) - RETURNING * - ) - SELECT COUNT(*)::INTEGER INTO v_deleted_count FROM deleted; - - RETURN QUERY - SELECT - v_deleted_count, - EXTRACT(EPOCH FROM (clock_timestamp() - v_start_time) * 1000)::NUMERIC; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION auth.cleanup_expired_sessions(INTEGER) IS - 'Deletes expired and old inactive sessions in batches. Returns deleted count and execution time.'; - --- Example usage: --- SELECT * FROM auth.cleanup_expired_sessions(1000); - --- Recommended: Schedule this function to run periodically via pg_cron or external scheduler --- Example pg_cron schedule (runs daily at 2 AM): --- SELECT cron.schedule('cleanup-expired-sessions', '0 2 * * *', --- 'SELECT auth.cleanup_expired_sessions(1000);'); diff --git a/apps/database/ddl/schemas/auth/functions/04-create_user_profile_trigger.sql b/apps/database/ddl/schemas/auth/functions/04-create_user_profile_trigger.sql deleted file mode 100644 index a01e6e9..0000000 --- a/apps/database/ddl/schemas/auth/functions/04-create_user_profile_trigger.sql +++ /dev/null @@ -1,46 +0,0 @@ --- ============================================================================ --- OrbiQuant IA - Trading Platform --- Schema: auth --- File: functions/04-create_user_profile_trigger.sql --- Description: Automatically create user profile when new user is created --- ============================================================================ - -CREATE OR REPLACE FUNCTION auth.create_user_profile() -RETURNS TRIGGER AS $$ -BEGIN - -- Create a new user profile for the newly created user - INSERT INTO auth.user_profiles ( - user_id, - language, - timezone, - newsletter_subscribed, - marketing_emails_enabled, - notifications_enabled, - created_at, - updated_at - ) VALUES ( - NEW.id, - 'en', - 'UTC', - false, - false, - true, - NOW(), - NOW() - ); - - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION auth.create_user_profile() IS - 'Trigger function to automatically create a user profile when a new user is registered'; - --- Create trigger on users table -CREATE TRIGGER trigger_create_user_profile - AFTER INSERT ON auth.users - FOR EACH ROW - EXECUTE FUNCTION auth.create_user_profile(); - -COMMENT ON TRIGGER trigger_create_user_profile ON auth.users IS - 'Automatically creates a user profile entry when a new user is inserted'; diff --git a/apps/database/ddl/schemas/auth/tables/01-users.sql b/apps/database/ddl/schemas/auth/tables/01-users.sql deleted file mode 100644 index b8d1e2f..0000000 --- a/apps/database/ddl/schemas/auth/tables/01-users.sql +++ /dev/null @@ -1,107 +0,0 @@ --- ============================================================================ --- OrbiQuant IA - Trading Platform --- Schema: auth --- File: tables/01-users.sql --- Description: Core users table for authentication and user management --- ============================================================================ - -CREATE TABLE auth.users ( - -- Primary Key - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Authentication Credentials - email CITEXT NOT NULL UNIQUE, - email_verified BOOLEAN NOT NULL DEFAULT false, - email_verified_at TIMESTAMPTZ, - password_hash VARCHAR(255), - - -- User Status and Role - status auth.user_status NOT NULL DEFAULT 'pending_verification', - role auth.user_role NOT NULL DEFAULT 'user', - - -- Multi-Factor Authentication - mfa_enabled BOOLEAN NOT NULL DEFAULT false, - mfa_method auth.mfa_method NOT NULL DEFAULT 'none', - mfa_secret VARCHAR(255), - backup_codes JSONB DEFAULT '[]', - - -- Phone Information - phone_number VARCHAR(20), - phone_verified BOOLEAN NOT NULL DEFAULT false, - phone_verified_at TIMESTAMPTZ, - - -- Security Settings - last_login_at TIMESTAMPTZ, - last_login_ip INET, - failed_login_attempts INTEGER NOT NULL DEFAULT 0, - locked_until TIMESTAMPTZ, - - -- Account Lifecycle - suspended_at TIMESTAMPTZ, - suspended_reason TEXT, - deactivated_at TIMESTAMPTZ, - - -- Audit Fields - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - created_by_id UUID, - updated_by_id UUID, - - -- Constraints - CONSTRAINT valid_email CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'), - CONSTRAINT password_or_oauth CHECK (password_hash IS NOT NULL OR EXISTS ( - SELECT 1 FROM auth.oauth_accounts WHERE user_id = users.id - )), - CONSTRAINT failed_attempts_non_negative CHECK (failed_login_attempts >= 0), - CONSTRAINT email_verified_at_consistency CHECK ( - (email_verified = true AND email_verified_at IS NOT NULL) OR - (email_verified = false AND email_verified_at IS NULL) - ), - CONSTRAINT phone_verified_at_consistency CHECK ( - (phone_verified = true AND phone_verified_at IS NOT NULL) OR - (phone_verified = false AND phone_verified_at IS NULL) - ), - CONSTRAINT mfa_secret_consistency CHECK ( - (mfa_enabled = true AND mfa_secret IS NOT NULL AND mfa_method != 'none') OR - (mfa_enabled = false) - ) -); - --- Indexes for Performance -CREATE INDEX idx_users_email ON auth.users(email); -CREATE INDEX idx_users_status ON auth.users(status); -CREATE INDEX idx_users_role ON auth.users(role); -CREATE INDEX idx_users_last_login ON auth.users(last_login_at DESC); -CREATE INDEX idx_users_created_at ON auth.users(created_at DESC); -CREATE INDEX idx_users_email_verified ON auth.users(email_verified) WHERE email_verified = false; -CREATE INDEX idx_users_locked ON auth.users(locked_until) WHERE locked_until IS NOT NULL; -CREATE INDEX idx_users_phone ON auth.users(phone_number) WHERE phone_number IS NOT NULL; - --- Table Comments -COMMENT ON TABLE auth.users IS 'Core users table for authentication and user management'; - --- Column Comments -COMMENT ON COLUMN auth.users.id IS 'Unique identifier for the user'; -COMMENT ON COLUMN auth.users.email IS 'User email address (case-insensitive, unique)'; -COMMENT ON COLUMN auth.users.email_verified IS 'Whether the email has been verified'; -COMMENT ON COLUMN auth.users.email_verified_at IS 'Timestamp when email was verified'; -COMMENT ON COLUMN auth.users.password_hash IS 'Bcrypt hashed password (null for OAuth-only users)'; -COMMENT ON COLUMN auth.users.status IS 'Current status of the user account'; -COMMENT ON COLUMN auth.users.role IS 'User role for role-based access control'; -COMMENT ON COLUMN auth.users.mfa_enabled IS 'Whether multi-factor authentication is enabled'; -COMMENT ON COLUMN auth.users.mfa_method IS 'MFA method used (totp, sms, email)'; -COMMENT ON COLUMN auth.users.mfa_secret IS 'Secret key for TOTP MFA'; -COMMENT ON COLUMN auth.users.phone_number IS 'User phone number for SMS verification'; -COMMENT ON COLUMN auth.users.phone_verified IS 'Whether the phone number has been verified'; -COMMENT ON COLUMN auth.users.phone_verified_at IS 'Timestamp when phone was verified'; -COMMENT ON COLUMN auth.users.last_login_at IS 'Timestamp of last successful login'; -COMMENT ON COLUMN auth.users.last_login_ip IS 'IP address of last successful login'; -COMMENT ON COLUMN auth.users.failed_login_attempts IS 'Counter for failed login attempts'; -COMMENT ON COLUMN auth.users.locked_until IS 'Account locked until this timestamp (null if not locked)'; -COMMENT ON COLUMN auth.users.suspended_at IS 'Timestamp when account was suspended'; -COMMENT ON COLUMN auth.users.suspended_reason IS 'Reason for account suspension'; -COMMENT ON COLUMN auth.users.deactivated_at IS 'Timestamp when account was deactivated'; -COMMENT ON COLUMN auth.users.created_at IS 'Timestamp when user was created'; -COMMENT ON COLUMN auth.users.updated_at IS 'Timestamp when user was last updated'; -COMMENT ON COLUMN auth.users.created_by_id IS 'ID of user who created this record'; -COMMENT ON COLUMN auth.users.updated_by_id IS 'ID of user who last updated this record'; diff --git a/apps/database/ddl/schemas/auth/tables/02-user_profiles.sql b/apps/database/ddl/schemas/auth/tables/02-user_profiles.sql deleted file mode 100644 index 2092a4a..0000000 --- a/apps/database/ddl/schemas/auth/tables/02-user_profiles.sql +++ /dev/null @@ -1,70 +0,0 @@ --- ============================================================================ --- OrbiQuant IA - Trading Platform --- Schema: auth --- File: tables/02-user_profiles.sql --- Description: Extended user profile information --- ============================================================================ - -CREATE TABLE auth.user_profiles ( - -- Primary Key - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Foreign Key to Users - user_id UUID NOT NULL UNIQUE, - - -- Personal Information - first_name VARCHAR(100), - last_name VARCHAR(100), - display_name VARCHAR(200), - avatar_url TEXT, - bio TEXT, - - -- Localization - language VARCHAR(10) DEFAULT 'en', - timezone VARCHAR(50) DEFAULT 'UTC', - country_code VARCHAR(2), - - -- Preferences - newsletter_subscribed BOOLEAN NOT NULL DEFAULT false, - marketing_emails_enabled BOOLEAN NOT NULL DEFAULT false, - notifications_enabled BOOLEAN NOT NULL DEFAULT true, - - -- Metadata - metadata JSONB DEFAULT '{}'::jsonb, - - -- Audit Fields - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - -- Foreign Key Constraints - CONSTRAINT fk_user_profiles_user FOREIGN KEY (user_id) - REFERENCES auth.users(id) - ON DELETE CASCADE -); - --- Indexes for Performance -CREATE INDEX idx_user_profiles_user_id ON auth.user_profiles(user_id); -CREATE INDEX idx_user_profiles_display_name ON auth.user_profiles(display_name); -CREATE INDEX idx_user_profiles_country ON auth.user_profiles(country_code); -CREATE INDEX idx_user_profiles_metadata ON auth.user_profiles USING gin(metadata); - --- Table Comments -COMMENT ON TABLE auth.user_profiles IS 'Extended user profile information and preferences'; - --- Column Comments -COMMENT ON COLUMN auth.user_profiles.id IS 'Unique identifier for the profile'; -COMMENT ON COLUMN auth.user_profiles.user_id IS 'Reference to the user account'; -COMMENT ON COLUMN auth.user_profiles.first_name IS 'User first name'; -COMMENT ON COLUMN auth.user_profiles.last_name IS 'User last name'; -COMMENT ON COLUMN auth.user_profiles.display_name IS 'Public display name'; -COMMENT ON COLUMN auth.user_profiles.avatar_url IS 'URL to user avatar image'; -COMMENT ON COLUMN auth.user_profiles.bio IS 'User biography or description'; -COMMENT ON COLUMN auth.user_profiles.language IS 'Preferred language (ISO 639-1 code)'; -COMMENT ON COLUMN auth.user_profiles.timezone IS 'User timezone (IANA timezone database)'; -COMMENT ON COLUMN auth.user_profiles.country_code IS 'Country code (ISO 3166-1 alpha-2)'; -COMMENT ON COLUMN auth.user_profiles.newsletter_subscribed IS 'Newsletter subscription preference'; -COMMENT ON COLUMN auth.user_profiles.marketing_emails_enabled IS 'Marketing emails preference'; -COMMENT ON COLUMN auth.user_profiles.notifications_enabled IS 'In-app notifications preference'; -COMMENT ON COLUMN auth.user_profiles.metadata IS 'Additional profile metadata as JSON'; -COMMENT ON COLUMN auth.user_profiles.created_at IS 'Timestamp when profile was created'; -COMMENT ON COLUMN auth.user_profiles.updated_at IS 'Timestamp when profile was last updated'; diff --git a/apps/database/ddl/schemas/auth/tables/03-oauth_accounts.sql b/apps/database/ddl/schemas/auth/tables/03-oauth_accounts.sql deleted file mode 100644 index 75fdba4..0000000 --- a/apps/database/ddl/schemas/auth/tables/03-oauth_accounts.sql +++ /dev/null @@ -1,69 +0,0 @@ --- ============================================================================ --- OrbiQuant IA - Trading Platform --- Schema: auth --- File: tables/03-oauth_accounts.sql --- Description: OAuth provider accounts linked to users --- ============================================================================ - -CREATE TABLE auth.oauth_accounts ( - -- Primary Key - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Foreign Key to Users - user_id UUID NOT NULL, - - -- OAuth Provider Information - provider auth.oauth_provider NOT NULL, - provider_account_id VARCHAR(255) NOT NULL, - provider_email CITEXT, - - -- OAuth Tokens - access_token TEXT, - refresh_token TEXT, - token_expires_at TIMESTAMPTZ, - - -- Provider Profile Data - profile_data JSONB DEFAULT '{}'::jsonb, - - -- Audit Fields - linked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - last_used_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - -- Foreign Key Constraints - CONSTRAINT fk_oauth_accounts_user FOREIGN KEY (user_id) - REFERENCES auth.users(id) - ON DELETE CASCADE, - - -- Unique Constraint: One provider account per user - CONSTRAINT unique_user_provider UNIQUE (user_id, provider), - - -- Unique Constraint: Provider account can only link to one user - CONSTRAINT unique_provider_account UNIQUE (provider, provider_account_id) -); - --- Indexes for Performance -CREATE INDEX idx_oauth_accounts_user_id ON auth.oauth_accounts(user_id); -CREATE INDEX idx_oauth_accounts_provider ON auth.oauth_accounts(provider); -CREATE INDEX idx_oauth_accounts_provider_email ON auth.oauth_accounts(provider_email); -CREATE INDEX idx_oauth_accounts_last_used ON auth.oauth_accounts(last_used_at DESC); -CREATE INDEX idx_oauth_accounts_profile_data ON auth.oauth_accounts USING gin(profile_data); - --- Table Comments -COMMENT ON TABLE auth.oauth_accounts IS 'OAuth provider accounts linked to users for social authentication'; - --- Column Comments -COMMENT ON COLUMN auth.oauth_accounts.id IS 'Unique identifier for the OAuth account'; -COMMENT ON COLUMN auth.oauth_accounts.user_id IS 'Reference to the user account'; -COMMENT ON COLUMN auth.oauth_accounts.provider IS 'OAuth provider (google, facebook, etc.)'; -COMMENT ON COLUMN auth.oauth_accounts.provider_account_id IS 'User ID from the OAuth provider'; -COMMENT ON COLUMN auth.oauth_accounts.provider_email IS 'Email address from OAuth provider'; -COMMENT ON COLUMN auth.oauth_accounts.access_token IS 'OAuth access token (encrypted)'; -COMMENT ON COLUMN auth.oauth_accounts.refresh_token IS 'OAuth refresh token (encrypted)'; -COMMENT ON COLUMN auth.oauth_accounts.token_expires_at IS 'Access token expiration timestamp'; -COMMENT ON COLUMN auth.oauth_accounts.profile_data IS 'Profile data from OAuth provider as JSON'; -COMMENT ON COLUMN auth.oauth_accounts.linked_at IS 'Timestamp when account was linked'; -COMMENT ON COLUMN auth.oauth_accounts.last_used_at IS 'Timestamp when last used for authentication'; -COMMENT ON COLUMN auth.oauth_accounts.created_at IS 'Timestamp when record was created'; -COMMENT ON COLUMN auth.oauth_accounts.updated_at IS 'Timestamp when record was last updated'; diff --git a/apps/database/ddl/schemas/auth/tables/04-sessions.sql b/apps/database/ddl/schemas/auth/tables/04-sessions.sql deleted file mode 100644 index b7d61de..0000000 --- a/apps/database/ddl/schemas/auth/tables/04-sessions.sql +++ /dev/null @@ -1,87 +0,0 @@ --- ============================================================================ --- OrbiQuant IA - Trading Platform --- Schema: auth --- File: tables/04-sessions.sql --- Description: User session management for authentication --- ============================================================================ - -CREATE TABLE auth.sessions ( - -- Primary Key - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Foreign Key to Users - user_id UUID NOT NULL, - - -- Session Token - session_token VARCHAR(255) NOT NULL UNIQUE, - - -- Session Lifecycle - expires_at TIMESTAMPTZ NOT NULL, - is_active BOOLEAN NOT NULL DEFAULT true, - - -- Session Metadata - ip_address INET, - user_agent TEXT, - device_type VARCHAR(50), - device_name VARCHAR(100), - browser VARCHAR(50), - os VARCHAR(50), - - -- Geolocation - country_code VARCHAR(2), - city VARCHAR(100), - - -- Security - last_activity_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - invalidated_at TIMESTAMPTZ, - invalidation_reason VARCHAR(100), - - -- Audit Fields - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - -- Foreign Key Constraints - CONSTRAINT fk_sessions_user FOREIGN KEY (user_id) - REFERENCES auth.users(id) - ON DELETE CASCADE, - - -- Check Constraints - CONSTRAINT valid_session_dates CHECK (expires_at > created_at), - CONSTRAINT invalidated_consistency CHECK ( - (is_active = false AND invalidated_at IS NOT NULL) OR - (is_active = true AND invalidated_at IS NULL) - ) -); - --- Indexes for Performance -CREATE INDEX idx_sessions_user_id ON auth.sessions(user_id); -CREATE INDEX idx_sessions_token ON auth.sessions(session_token); -CREATE INDEX idx_sessions_expires_at ON auth.sessions(expires_at); -CREATE INDEX idx_sessions_active ON auth.sessions(is_active, expires_at) WHERE is_active = true; -CREATE INDEX idx_sessions_last_activity ON auth.sessions(last_activity_at DESC); -CREATE INDEX idx_sessions_ip_address ON auth.sessions(ip_address); -CREATE INDEX idx_sessions_user_active ON auth.sessions(user_id, is_active, expires_at) - WHERE is_active = true; - --- Table Comments -COMMENT ON TABLE auth.sessions IS 'User session management for authentication and activity tracking'; - --- Column Comments -COMMENT ON COLUMN auth.sessions.id IS 'Unique identifier for the session'; -COMMENT ON COLUMN auth.sessions.user_id IS 'Reference to the user account'; -COMMENT ON COLUMN auth.sessions.session_token IS 'Unique session token for authentication'; -COMMENT ON COLUMN auth.sessions.expires_at IS 'Session expiration timestamp'; -COMMENT ON COLUMN auth.sessions.is_active IS 'Whether the session is currently active'; -COMMENT ON COLUMN auth.sessions.ip_address IS 'IP address of the session'; -COMMENT ON COLUMN auth.sessions.user_agent IS 'User agent string from the browser'; -COMMENT ON COLUMN auth.sessions.device_type IS 'Device type (desktop, mobile, tablet)'; -COMMENT ON COLUMN auth.sessions.device_name IS 'Device name or model'; -COMMENT ON COLUMN auth.sessions.browser IS 'Browser name and version'; -COMMENT ON COLUMN auth.sessions.os IS 'Operating system name and version'; -COMMENT ON COLUMN auth.sessions.country_code IS 'Country code from IP geolocation'; -COMMENT ON COLUMN auth.sessions.city IS 'City from IP geolocation'; -COMMENT ON COLUMN auth.sessions.last_activity_at IS 'Timestamp of last session activity'; -COMMENT ON COLUMN auth.sessions.invalidated_at IS 'Timestamp when session was invalidated'; -COMMENT ON COLUMN auth.sessions.invalidation_reason IS 'Reason for session invalidation'; -COMMENT ON COLUMN auth.sessions.created_at IS 'Timestamp when session was created'; -COMMENT ON COLUMN auth.sessions.updated_at IS 'Timestamp when session was last updated'; diff --git a/apps/database/ddl/schemas/auth/tables/05-email_verifications.sql b/apps/database/ddl/schemas/auth/tables/05-email_verifications.sql deleted file mode 100644 index 8995898..0000000 --- a/apps/database/ddl/schemas/auth/tables/05-email_verifications.sql +++ /dev/null @@ -1,65 +0,0 @@ --- ============================================================================ --- OrbiQuant IA - Trading Platform --- Schema: auth --- File: tables/05-email_verifications.sql --- Description: Email verification tokens and tracking --- ============================================================================ - -CREATE TABLE auth.email_verifications ( - -- Primary Key - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Foreign Key to Users - user_id UUID NOT NULL, - - -- Email and Token - email CITEXT NOT NULL, - token VARCHAR(255) NOT NULL UNIQUE, - - -- Token Lifecycle - expires_at TIMESTAMPTZ NOT NULL, - verified_at TIMESTAMPTZ, - is_verified BOOLEAN NOT NULL DEFAULT false, - - -- Metadata - ip_address INET, - user_agent TEXT, - - -- Audit Fields - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - -- Foreign Key Constraints - CONSTRAINT fk_email_verifications_user FOREIGN KEY (user_id) - REFERENCES auth.users(id) - ON DELETE CASCADE, - - -- Check Constraints - CONSTRAINT valid_expiration CHECK (expires_at > created_at), - CONSTRAINT verified_consistency CHECK ( - (is_verified = true AND verified_at IS NOT NULL) OR - (is_verified = false AND verified_at IS NULL) - ) -); - --- Indexes for Performance -CREATE INDEX idx_email_verifications_user_id ON auth.email_verifications(user_id); -CREATE INDEX idx_email_verifications_token ON auth.email_verifications(token); -CREATE INDEX idx_email_verifications_email ON auth.email_verifications(email); -CREATE INDEX idx_email_verifications_expires ON auth.email_verifications(expires_at); -CREATE INDEX idx_email_verifications_pending ON auth.email_verifications(user_id, is_verified, expires_at) - WHERE is_verified = false; - --- Table Comments -COMMENT ON TABLE auth.email_verifications IS 'Email verification tokens and tracking for user email confirmation'; - --- Column Comments -COMMENT ON COLUMN auth.email_verifications.id IS 'Unique identifier for the verification record'; -COMMENT ON COLUMN auth.email_verifications.user_id IS 'Reference to the user account'; -COMMENT ON COLUMN auth.email_verifications.email IS 'Email address to be verified'; -COMMENT ON COLUMN auth.email_verifications.token IS 'Unique verification token sent to email'; -COMMENT ON COLUMN auth.email_verifications.expires_at IS 'Token expiration timestamp'; -COMMENT ON COLUMN auth.email_verifications.verified_at IS 'Timestamp when email was verified'; -COMMENT ON COLUMN auth.email_verifications.is_verified IS 'Whether the email has been verified'; -COMMENT ON COLUMN auth.email_verifications.ip_address IS 'IP address when verification was requested'; -COMMENT ON COLUMN auth.email_verifications.user_agent IS 'User agent when verification was requested'; -COMMENT ON COLUMN auth.email_verifications.created_at IS 'Timestamp when verification was created'; diff --git a/apps/database/ddl/schemas/auth/tables/06-phone_verifications.sql b/apps/database/ddl/schemas/auth/tables/06-phone_verifications.sql deleted file mode 100644 index e809854..0000000 --- a/apps/database/ddl/schemas/auth/tables/06-phone_verifications.sql +++ /dev/null @@ -1,78 +0,0 @@ --- ============================================================================ --- OrbiQuant IA - Trading Platform --- Schema: auth --- File: tables/06-phone_verifications.sql --- Description: Phone number verification tokens and tracking --- ============================================================================ - -CREATE TABLE auth.phone_verifications ( - -- Primary Key - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Foreign Key to Users - user_id UUID NOT NULL, - - -- Phone and Verification Code - phone_number VARCHAR(20) NOT NULL, - verification_code VARCHAR(10) NOT NULL, - channel auth.phone_channel NOT NULL DEFAULT 'sms', - - -- Token Lifecycle - expires_at TIMESTAMPTZ NOT NULL, - verified_at TIMESTAMPTZ, - is_verified BOOLEAN NOT NULL DEFAULT false, - - -- Attempt Tracking - send_attempts INTEGER NOT NULL DEFAULT 0, - verification_attempts INTEGER NOT NULL DEFAULT 0, - max_attempts INTEGER NOT NULL DEFAULT 3, - - -- Metadata - ip_address INET, - user_agent TEXT, - - -- Audit Fields - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - -- Foreign Key Constraints - CONSTRAINT fk_phone_verifications_user FOREIGN KEY (user_id) - REFERENCES auth.users(id) - ON DELETE CASCADE, - - -- Check Constraints - CONSTRAINT valid_expiration CHECK (expires_at > created_at), - CONSTRAINT verified_consistency CHECK ( - (is_verified = true AND verified_at IS NOT NULL) OR - (is_verified = false AND verified_at IS NULL) - ), - CONSTRAINT valid_attempts CHECK ( - send_attempts >= 0 AND - verification_attempts >= 0 AND - verification_attempts <= max_attempts - ) -); - --- Indexes for Performance -CREATE INDEX idx_phone_verifications_user_id ON auth.phone_verifications(user_id); -CREATE INDEX idx_phone_verifications_phone ON auth.phone_verifications(phone_number); -CREATE INDEX idx_phone_verifications_expires ON auth.phone_verifications(expires_at); -CREATE INDEX idx_phone_verifications_pending ON auth.phone_verifications(user_id, is_verified, expires_at) - WHERE is_verified = false; - --- Table Comments -COMMENT ON TABLE auth.phone_verifications IS 'Phone number verification codes and tracking for user phone confirmation'; - --- Column Comments -COMMENT ON COLUMN auth.phone_verifications.id IS 'Unique identifier for the verification record'; -COMMENT ON COLUMN auth.phone_verifications.user_id IS 'Reference to the user account'; -COMMENT ON COLUMN auth.phone_verifications.phone_number IS 'Phone number to be verified'; -COMMENT ON COLUMN auth.phone_verifications.verification_code IS 'Verification code sent via SMS'; -COMMENT ON COLUMN auth.phone_verifications.expires_at IS 'Code expiration timestamp'; -COMMENT ON COLUMN auth.phone_verifications.verified_at IS 'Timestamp when phone was verified'; -COMMENT ON COLUMN auth.phone_verifications.is_verified IS 'Whether the phone has been verified'; -COMMENT ON COLUMN auth.phone_verifications.send_attempts IS 'Number of times code was sent'; -COMMENT ON COLUMN auth.phone_verifications.verification_attempts IS 'Number of verification attempts'; -COMMENT ON COLUMN auth.phone_verifications.max_attempts IS 'Maximum allowed verification attempts'; -COMMENT ON COLUMN auth.phone_verifications.ip_address IS 'IP address when verification was requested'; -COMMENT ON COLUMN auth.phone_verifications.user_agent IS 'User agent when verification was requested'; -COMMENT ON COLUMN auth.phone_verifications.created_at IS 'Timestamp when verification was created'; diff --git a/apps/database/ddl/schemas/auth/tables/07-password_reset_tokens.sql b/apps/database/ddl/schemas/auth/tables/07-password_reset_tokens.sql deleted file mode 100644 index b18b2cb..0000000 --- a/apps/database/ddl/schemas/auth/tables/07-password_reset_tokens.sql +++ /dev/null @@ -1,65 +0,0 @@ --- ============================================================================ --- OrbiQuant IA - Trading Platform --- Schema: auth --- File: tables/07-password_reset_tokens.sql --- Description: Password reset tokens and tracking --- ============================================================================ - -CREATE TABLE auth.password_reset_tokens ( - -- Primary Key - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Foreign Key to Users - user_id UUID NOT NULL, - - -- Email and Token - email CITEXT NOT NULL, - token VARCHAR(255) NOT NULL UNIQUE, - - -- Token Lifecycle - expires_at TIMESTAMPTZ NOT NULL, - used_at TIMESTAMPTZ, - is_used BOOLEAN NOT NULL DEFAULT false, - - -- Metadata - ip_address INET, - user_agent TEXT, - - -- Audit Fields - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - -- Foreign Key Constraints - CONSTRAINT fk_password_reset_tokens_user FOREIGN KEY (user_id) - REFERENCES auth.users(id) - ON DELETE CASCADE, - - -- Check Constraints - CONSTRAINT valid_expiration CHECK (expires_at > created_at), - CONSTRAINT used_consistency CHECK ( - (is_used = true AND used_at IS NOT NULL) OR - (is_used = false AND used_at IS NULL) - ) -); - --- Indexes for Performance -CREATE INDEX idx_password_reset_tokens_user_id ON auth.password_reset_tokens(user_id); -CREATE INDEX idx_password_reset_tokens_token ON auth.password_reset_tokens(token); -CREATE INDEX idx_password_reset_tokens_email ON auth.password_reset_tokens(email); -CREATE INDEX idx_password_reset_tokens_expires ON auth.password_reset_tokens(expires_at); -CREATE INDEX idx_password_reset_tokens_active ON auth.password_reset_tokens(user_id, is_used, expires_at) - WHERE is_used = false; - --- Table Comments -COMMENT ON TABLE auth.password_reset_tokens IS 'Password reset tokens for secure password recovery'; - --- Column Comments -COMMENT ON COLUMN auth.password_reset_tokens.id IS 'Unique identifier for the reset token'; -COMMENT ON COLUMN auth.password_reset_tokens.user_id IS 'Reference to the user account'; -COMMENT ON COLUMN auth.password_reset_tokens.email IS 'Email address for password reset'; -COMMENT ON COLUMN auth.password_reset_tokens.token IS 'Unique reset token sent to email'; -COMMENT ON COLUMN auth.password_reset_tokens.expires_at IS 'Token expiration timestamp'; -COMMENT ON COLUMN auth.password_reset_tokens.used_at IS 'Timestamp when token was used'; -COMMENT ON COLUMN auth.password_reset_tokens.is_used IS 'Whether the token has been used'; -COMMENT ON COLUMN auth.password_reset_tokens.ip_address IS 'IP address when reset was requested'; -COMMENT ON COLUMN auth.password_reset_tokens.user_agent IS 'User agent when reset was requested'; -COMMENT ON COLUMN auth.password_reset_tokens.created_at IS 'Timestamp when token was created'; diff --git a/apps/database/ddl/schemas/auth/tables/08-auth_logs.sql b/apps/database/ddl/schemas/auth/tables/08-auth_logs.sql deleted file mode 100644 index 810c96c..0000000 --- a/apps/database/ddl/schemas/auth/tables/08-auth_logs.sql +++ /dev/null @@ -1,74 +0,0 @@ --- ============================================================================ --- OrbiQuant IA - Trading Platform --- Schema: auth --- File: tables/08-auth_logs.sql --- Description: Authentication event audit logging with optional partitioning --- ============================================================================ - -CREATE TABLE auth.auth_logs ( - -- Primary Key - id UUID DEFAULT gen_random_uuid(), - - -- Event Information - event_type auth.auth_event_type NOT NULL, - user_id UUID, - email CITEXT, - - -- Request Context - ip_address INET, - user_agent TEXT, - session_id UUID, - - -- Event Details - success BOOLEAN NOT NULL DEFAULT false, - failure_reason VARCHAR(255), - - -- Additional Metadata - metadata JSONB DEFAULT '{}'::jsonb, - - -- Timestamp (partition key) - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - -- Primary Key includes partition key for partitioned tables - PRIMARY KEY (id, created_at) -) PARTITION BY RANGE (created_at); - --- Create initial partitions for current and next month --- These should be created dynamically by a maintenance job in production - --- Current month partition -CREATE TABLE auth.auth_logs_current PARTITION OF auth.auth_logs - FOR VALUES FROM (DATE_TRUNC('month', CURRENT_DATE)) - TO (DATE_TRUNC('month', CURRENT_DATE + INTERVAL '1 month')); - --- Next month partition -CREATE TABLE auth.auth_logs_next PARTITION OF auth.auth_logs - FOR VALUES FROM (DATE_TRUNC('month', CURRENT_DATE + INTERVAL '1 month')) - TO (DATE_TRUNC('month', CURRENT_DATE + INTERVAL '2 months')); - --- Indexes for Performance (will be inherited by partitions) -CREATE INDEX idx_auth_logs_user_id ON auth.auth_logs(user_id, created_at DESC); -CREATE INDEX idx_auth_logs_email ON auth.auth_logs(email, created_at DESC); -CREATE INDEX idx_auth_logs_event_type ON auth.auth_logs(event_type, created_at DESC); -CREATE INDEX idx_auth_logs_ip_address ON auth.auth_logs(ip_address, created_at DESC); -CREATE INDEX idx_auth_logs_session_id ON auth.auth_logs(session_id); -CREATE INDEX idx_auth_logs_created_at ON auth.auth_logs(created_at DESC); -CREATE INDEX idx_auth_logs_failures ON auth.auth_logs(user_id, created_at DESC) - WHERE success = false; -CREATE INDEX idx_auth_logs_metadata ON auth.auth_logs USING gin(metadata); - --- Table Comments -COMMENT ON TABLE auth.auth_logs IS 'Authentication event audit logging with monthly partitioning for performance'; - --- Column Comments -COMMENT ON COLUMN auth.auth_logs.id IS 'Unique identifier for the log entry'; -COMMENT ON COLUMN auth.auth_logs.event_type IS 'Type of authentication event'; -COMMENT ON COLUMN auth.auth_logs.user_id IS 'Reference to the user (null for failed logins)'; -COMMENT ON COLUMN auth.auth_logs.email IS 'Email address associated with the event'; -COMMENT ON COLUMN auth.auth_logs.ip_address IS 'IP address of the request'; -COMMENT ON COLUMN auth.auth_logs.user_agent IS 'User agent string from the request'; -COMMENT ON COLUMN auth.auth_logs.session_id IS 'Session ID if applicable'; -COMMENT ON COLUMN auth.auth_logs.success IS 'Whether the event was successful'; -COMMENT ON COLUMN auth.auth_logs.failure_reason IS 'Reason for failure if applicable'; -COMMENT ON COLUMN auth.auth_logs.metadata IS 'Additional event metadata as JSON'; -COMMENT ON COLUMN auth.auth_logs.created_at IS 'Timestamp when event occurred (partition key)'; diff --git a/apps/database/ddl/schemas/auth/tables/09-login_attempts.sql b/apps/database/ddl/schemas/auth/tables/09-login_attempts.sql deleted file mode 100644 index bb4d9e3..0000000 --- a/apps/database/ddl/schemas/auth/tables/09-login_attempts.sql +++ /dev/null @@ -1,67 +0,0 @@ --- ============================================================================ --- OrbiQuant IA - Trading Platform --- Schema: auth --- File: tables/09-login_attempts.sql --- Description: Login attempt tracking for rate limiting and security monitoring --- ============================================================================ - -CREATE TABLE auth.login_attempts ( - -- Primary Key - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Attempt Information - email CITEXT, - user_id UUID, - - -- Request Context - ip_address INET NOT NULL, - user_agent TEXT, - - -- Attempt Result - success BOOLEAN NOT NULL, - failure_reason VARCHAR(100), - - -- Additional Details - attempted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - -- Metadata - metadata JSONB DEFAULT '{}'::jsonb, - - -- Audit Fields - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - -- Check Constraints - CONSTRAINT login_attempt_has_identifier CHECK ( - email IS NOT NULL OR user_id IS NOT NULL - ), - CONSTRAINT failure_reason_consistency CHECK ( - (success = false AND failure_reason IS NOT NULL) OR - (success = true AND failure_reason IS NULL) - ) -); - --- Indexes for Performance -CREATE INDEX idx_login_attempts_email ON auth.login_attempts(email, created_at DESC); -CREATE INDEX idx_login_attempts_user_id ON auth.login_attempts(user_id, created_at DESC); -CREATE INDEX idx_login_attempts_ip ON auth.login_attempts(ip_address, created_at DESC); -CREATE INDEX idx_login_attempts_created ON auth.login_attempts(created_at DESC); -CREATE INDEX idx_login_attempts_failures ON auth.login_attempts(email, ip_address, created_at DESC) - WHERE success = false; -CREATE INDEX idx_login_attempts_success ON auth.login_attempts(email, created_at DESC) - WHERE success = true; -CREATE INDEX idx_login_attempts_metadata ON auth.login_attempts USING gin(metadata); - --- Table Comments -COMMENT ON TABLE auth.login_attempts IS 'Login attempt tracking for rate limiting, brute force detection, and security monitoring'; - --- Column Comments -COMMENT ON COLUMN auth.login_attempts.id IS 'Unique identifier for the login attempt'; -COMMENT ON COLUMN auth.login_attempts.email IS 'Email address used in login attempt'; -COMMENT ON COLUMN auth.login_attempts.user_id IS 'User ID if resolved from email'; -COMMENT ON COLUMN auth.login_attempts.ip_address IS 'IP address of the login attempt'; -COMMENT ON COLUMN auth.login_attempts.user_agent IS 'User agent string from the request'; -COMMENT ON COLUMN auth.login_attempts.success IS 'Whether the login attempt was successful'; -COMMENT ON COLUMN auth.login_attempts.failure_reason IS 'Reason for login failure (invalid_password, account_locked, etc.)'; -COMMENT ON COLUMN auth.login_attempts.attempted_at IS 'Timestamp when login was attempted'; -COMMENT ON COLUMN auth.login_attempts.metadata IS 'Additional attempt metadata as JSON'; -COMMENT ON COLUMN auth.login_attempts.created_at IS 'Timestamp when record was created'; diff --git a/apps/database/ddl/schemas/auth/tables/10-rate_limiting_config.sql b/apps/database/ddl/schemas/auth/tables/10-rate_limiting_config.sql deleted file mode 100644 index b70162c..0000000 --- a/apps/database/ddl/schemas/auth/tables/10-rate_limiting_config.sql +++ /dev/null @@ -1,82 +0,0 @@ --- ============================================================================ --- OrbiQuant IA - Trading Platform --- Schema: auth --- File: tables/10-rate_limiting_config.sql --- Description: Rate limiting configuration for API endpoints and auth operations --- ============================================================================ - -CREATE TABLE auth.rate_limiting_config ( - -- Primary Key - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Endpoint Configuration - endpoint VARCHAR(200) NOT NULL UNIQUE, - description TEXT, - - -- Rate Limiting Parameters - max_requests INTEGER NOT NULL DEFAULT 100, - window_seconds INTEGER NOT NULL DEFAULT 60, - block_duration_seconds INTEGER DEFAULT 300, - - -- Scope Configuration - scope VARCHAR(50) NOT NULL DEFAULT 'ip', - - -- Status - is_active BOOLEAN NOT NULL DEFAULT true, - - -- Metadata - metadata JSONB DEFAULT '{}'::jsonb, - - -- Audit Fields - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - created_by_id UUID, - updated_by_id UUID, - - -- Check Constraints - CONSTRAINT valid_rate_limits CHECK ( - max_requests > 0 AND - window_seconds > 0 AND - (block_duration_seconds IS NULL OR block_duration_seconds > 0) - ), - CONSTRAINT valid_scope CHECK ( - scope IN ('ip', 'user', 'email', 'global') - ) -); - --- Indexes for Performance -CREATE INDEX idx_rate_limiting_endpoint ON auth.rate_limiting_config(endpoint); -CREATE INDEX idx_rate_limiting_active ON auth.rate_limiting_config(is_active) WHERE is_active = true; -CREATE INDEX idx_rate_limiting_scope ON auth.rate_limiting_config(scope); -CREATE INDEX idx_rate_limiting_metadata ON auth.rate_limiting_config USING gin(metadata); - --- Insert Default Rate Limiting Rules -INSERT INTO auth.rate_limiting_config (endpoint, description, max_requests, window_seconds, block_duration_seconds, scope) VALUES - ('/auth/login', 'Login endpoint rate limit', 5, 300, 900, 'ip'), - ('/auth/register', 'Registration endpoint rate limit', 3, 3600, 1800, 'ip'), - ('/auth/password-reset/request', 'Password reset request limit', 3, 3600, 1800, 'email'), - ('/auth/password-reset/verify', 'Password reset verification limit', 5, 300, 900, 'ip'), - ('/auth/verify-email', 'Email verification limit', 10, 3600, 1800, 'user'), - ('/auth/verify-phone', 'Phone verification limit', 5, 3600, 1800, 'user'), - ('/auth/refresh-token', 'Token refresh limit', 20, 300, 600, 'user'), - ('/auth/logout', 'Logout endpoint limit', 10, 60, NULL, 'user'), - ('/auth/mfa/enable', 'MFA enable limit', 5, 3600, NULL, 'user'), - ('/auth/mfa/verify', 'MFA verification limit', 5, 300, 900, 'user'); - --- Table Comments -COMMENT ON TABLE auth.rate_limiting_config IS 'Rate limiting configuration for API endpoints to prevent abuse and brute force attacks'; - --- Column Comments -COMMENT ON COLUMN auth.rate_limiting_config.id IS 'Unique identifier for the configuration'; -COMMENT ON COLUMN auth.rate_limiting_config.endpoint IS 'API endpoint path to rate limit'; -COMMENT ON COLUMN auth.rate_limiting_config.description IS 'Description of the rate limit purpose'; -COMMENT ON COLUMN auth.rate_limiting_config.max_requests IS 'Maximum requests allowed within the time window'; -COMMENT ON COLUMN auth.rate_limiting_config.window_seconds IS 'Time window in seconds for rate limiting'; -COMMENT ON COLUMN auth.rate_limiting_config.block_duration_seconds IS 'Duration to block after exceeding limit (null for no block)'; -COMMENT ON COLUMN auth.rate_limiting_config.scope IS 'Scope of rate limit (ip, user, email, global)'; -COMMENT ON COLUMN auth.rate_limiting_config.is_active IS 'Whether this rate limit is currently active'; -COMMENT ON COLUMN auth.rate_limiting_config.metadata IS 'Additional configuration metadata as JSON'; -COMMENT ON COLUMN auth.rate_limiting_config.created_at IS 'Timestamp when configuration was created'; -COMMENT ON COLUMN auth.rate_limiting_config.updated_at IS 'Timestamp when configuration was last updated'; -COMMENT ON COLUMN auth.rate_limiting_config.created_by_id IS 'ID of user who created this configuration'; -COMMENT ON COLUMN auth.rate_limiting_config.updated_by_id IS 'ID of user who last updated this configuration'; diff --git a/apps/database/ddl/schemas/education/00-enums.sql b/apps/database/ddl/schemas/education/00-enums.sql deleted file mode 100644 index e235aa8..0000000 --- a/apps/database/ddl/schemas/education/00-enums.sql +++ /dev/null @@ -1,64 +0,0 @@ --- ===================================================== --- ENUMS - Schema Education --- ===================================================== --- Proyecto: OrbiQuant IA (Trading Platform) --- Módulo: OQI-002 - Education --- Especificación: ET-EDU-001-database.md --- PostgreSQL: 15+ --- ===================================================== - --- Nivel de dificultad -CREATE TYPE education.difficulty_level AS ENUM ( - 'beginner', - 'intermediate', - 'advanced', - 'expert' -); - --- Estado de curso -CREATE TYPE education.course_status AS ENUM ( - 'draft', - 'published', - 'archived' -); - --- Estado de enrollment -CREATE TYPE education.enrollment_status AS ENUM ( - 'active', - 'completed', - 'expired', - 'cancelled' -); - --- Tipo de contenido de lección -CREATE TYPE education.lesson_content_type AS ENUM ( - 'video', - 'article', - 'interactive', - 'quiz' -); - --- Tipo de pregunta de quiz -CREATE TYPE education.question_type AS ENUM ( - 'multiple_choice', - 'true_false', - 'multiple_select', - 'fill_blank', - 'code_challenge' -); - --- Tipo de logro/badge -CREATE TYPE education.achievement_type AS ENUM ( - 'course_completion', - 'quiz_perfect_score', - 'streak_milestone', - 'level_up', - 'special_event' -); - -COMMENT ON TYPE education.difficulty_level IS 'Nivel de dificultad de cursos'; -COMMENT ON TYPE education.course_status IS 'Estado del curso (draft, published, archived)'; -COMMENT ON TYPE education.enrollment_status IS 'Estado de la inscripción del usuario'; -COMMENT ON TYPE education.lesson_content_type IS 'Tipo de contenido de la lección'; -COMMENT ON TYPE education.question_type IS 'Tipo de pregunta en quizzes'; -COMMENT ON TYPE education.achievement_type IS 'Tipo de logro/badge'; diff --git a/apps/database/ddl/schemas/education/README.md b/apps/database/ddl/schemas/education/README.md deleted file mode 100644 index 235a4cf..0000000 --- a/apps/database/ddl/schemas/education/README.md +++ /dev/null @@ -1,353 +0,0 @@ -# Schema: education - -**Proyecto:** OrbiQuant IA (Trading Platform) -**Módulo:** OQI-002 - Education -**Especificación:** ET-EDU-001-database.md -**PostgreSQL:** 15+ - ---- - -## Descripción - -Schema completo para el módulo educativo de OrbiQuant IA, implementando: -- Gestión de cursos, módulos y lecciones -- Sistema de enrollments y progreso de estudiantes -- Quizzes y evaluaciones -- Certificados de finalización -- Sistema de gamificación (XP, niveles, streaks, achievements) -- Reviews de cursos -- Activity logging - ---- - -## Estructura de Archivos - -``` -education/ -├── 00-enums.sql # Tipos ENUM -├── tables/ -│ ├── 01-categories.sql # Categorías de cursos -│ ├── 02-courses.sql # Cursos -│ ├── 03-modules.sql # Módulos del curso -│ ├── 04-lessons.sql # Lecciones -│ ├── 05-enrollments.sql # Inscripciones de usuarios -│ ├── 06-progress.sql # Progreso en lecciones -│ ├── 07-quizzes.sql # Quizzes/evaluaciones -│ ├── 08-quiz_questions.sql # Preguntas de quiz -│ ├── 09-quiz_attempts.sql # Intentos de quiz -│ ├── 10-certificates.sql # Certificados -│ ├── 11-user_achievements.sql # Logros/badges -│ ├── 12-user_gamification_profile.sql # Perfil de gamificación -│ ├── 13-user_activity_log.sql # Log de actividades -│ └── 14-course_reviews.sql # Reviews de cursos -└── functions/ - ├── 01-update_updated_at.sql # Trigger updated_at - ├── 02-update_enrollment_progress.sql # Actualizar progreso - ├── 03-auto_complete_enrollment.sql # Auto-completar enrollment - ├── 04-generate_certificate.sql # Generar certificados - ├── 05-update_course_stats.sql # Actualizar estadísticas - ├── 06-update_enrollment_count.sql # Contador de enrollments - ├── 07-update_gamification_profile.sql # Sistema de gamificación - └── 08-views.sql # Vistas útiles -``` - ---- - -## Orden de Ejecución - -Para crear el schema completo, ejecutar en este orden: - -```bash -# 1. ENUMs -psql -f 00-enums.sql - -# 2. Tablas (en orden de dependencias) -psql -f tables/01-categories.sql -psql -f tables/02-courses.sql -psql -f tables/03-modules.sql -psql -f tables/04-lessons.sql -psql -f tables/05-enrollments.sql -psql -f tables/06-progress.sql -psql -f tables/07-quizzes.sql -psql -f tables/08-quiz_questions.sql -psql -f tables/09-quiz_attempts.sql -psql -f tables/10-certificates.sql -psql -f tables/11-user_achievements.sql -psql -f tables/12-user_gamification_profile.sql -psql -f tables/13-user_activity_log.sql -psql -f tables/14-course_reviews.sql - -# 3. Funciones y Triggers -psql -f functions/01-update_updated_at.sql -psql -f functions/02-update_enrollment_progress.sql -psql -f functions/03-auto_complete_enrollment.sql -psql -f functions/04-generate_certificate.sql -psql -f functions/05-update_course_stats.sql -psql -f functions/06-update_enrollment_count.sql -psql -f functions/07-update_gamification_profile.sql -psql -f functions/08-views.sql -``` - ---- - -## Tablas - -### Principales - -| Tabla | Descripción | -|-------|-------------| -| `categories` | Categorías de cursos con soporte para jerarquía | -| `courses` | Cursos del módulo educativo | -| `modules` | Módulos que agrupan lecciones | -| `lessons` | Lecciones individuales (video, artículo, interactivo) | -| `enrollments` | Inscripciones de usuarios a cursos | -| `progress` | Progreso del usuario en cada lección | - -### Evaluación - -| Tabla | Descripción | -|-------|-------------| -| `quizzes` | Quizzes/evaluaciones | -| `quiz_questions` | Preguntas de los quizzes | -| `quiz_attempts` | Intentos de usuarios en quizzes | - -### Logros - -| Tabla | Descripción | -|-------|-------------| -| `certificates` | Certificados de finalización | -| `user_achievements` | Logros/badges obtenidos | - -### Gamificación - -| Tabla | Descripción | -|-------|-------------| -| `user_gamification_profile` | XP, niveles, streaks, estadísticas | -| `user_activity_log` | Log de todas las actividades | -| `course_reviews` | Reviews y calificaciones de cursos | - ---- - -## ENUMs - -- `difficulty_level`: beginner, intermediate, advanced, expert -- `course_status`: draft, published, archived -- `enrollment_status`: active, completed, expired, cancelled -- `lesson_content_type`: video, article, interactive, quiz -- `question_type`: multiple_choice, true_false, multiple_select, fill_blank, code_challenge -- `achievement_type`: course_completion, quiz_perfect_score, streak_milestone, level_up, special_event - ---- - -## Funciones Principales - -### Gamificación - -```sql --- Actualizar XP del usuario -SELECT education.update_user_xp( - 'user-uuid', -- user_id - 100 -- xp_to_add -); - --- Actualizar streak del usuario -SELECT education.update_user_streak('user-uuid'); -``` - -### Triggers Automáticos - -- `updated_at`: Se actualiza automáticamente en todas las tablas -- `update_enrollment_progress()`: Calcula progreso al completar lecciones -- `auto_complete_enrollment()`: Completa enrollment al alcanzar 100% -- `generate_certificate_number()`: Genera número único de certificado -- `update_course_rating_stats()`: Actualiza rating promedio del curso -- `update_enrollment_count()`: Actualiza contador de enrollments -- `update_streak_on_activity()`: Actualiza streak en cada actividad - ---- - -## Vistas - -| Vista | Descripción | -|-------|-------------| -| `v_courses_with_stats` | Cursos con estadísticas agregadas | -| `v_user_course_progress` | Progreso del usuario por curso | -| `v_leaderboard_weekly` | Top 100 usuarios por XP semanal | -| `v_leaderboard_monthly` | Top 100 usuarios por XP mensual | -| `v_leaderboard_alltime` | Top 100 usuarios por XP total | -| `v_user_statistics` | Estadísticas completas del usuario | -| `v_popular_courses` | Top 50 cursos más populares | - ---- - -## Dependencias - -### Schemas externos -- `auth.users` - Tabla de usuarios (requerida) - -### Extensions -- `gen_random_uuid()` - Built-in en PostgreSQL 13+ - ---- - -## Políticas de Seguridad (RLS) - -Para habilitar Row Level Security (implementar según necesidad): - -```sql --- Habilitar RLS -ALTER TABLE education.enrollments ENABLE ROW LEVEL SECURITY; -ALTER TABLE education.progress ENABLE ROW LEVEL SECURITY; -ALTER TABLE education.quiz_attempts ENABLE ROW LEVEL SECURITY; -ALTER TABLE education.certificates ENABLE ROW LEVEL SECURITY; - --- Política: usuarios solo ven sus propios datos -CREATE POLICY user_own_data ON education.enrollments - FOR ALL - USING (user_id = current_setting('app.user_id')::UUID); -``` - ---- - -## Ejemplos de Uso - -### Enrollar usuario a un curso - -```sql -INSERT INTO education.enrollments (user_id, course_id) -VALUES ('user-uuid', 'course-uuid') -RETURNING *; -``` - -### Registrar progreso en lección - -```sql -INSERT INTO education.progress ( - user_id, - lesson_id, - enrollment_id, - is_completed, - watch_percentage -) VALUES ( - 'user-uuid', - 'lesson-uuid', - 'enrollment-uuid', - true, - 100.00 -); --- Esto automáticamente actualizará el enrollment progress -``` - -### Completar quiz - -```sql -INSERT INTO education.quiz_attempts ( - user_id, - quiz_id, - enrollment_id, - is_completed, - is_passed, - user_answers, - score_percentage, - xp_earned -) VALUES ( - 'user-uuid', - 'quiz-uuid', - 'enrollment-uuid', - true, - true, - '[{"questionId": "q1", "answer": "A", "isCorrect": true}]'::jsonb, - 85.00, - 50 -); -``` - -### Emitir certificado - -```sql -INSERT INTO education.certificates ( - user_id, - course_id, - enrollment_id, - user_name, - course_title, - completion_date, - final_score -) VALUES ( - 'user-uuid', - 'course-uuid', - 'enrollment-uuid', - 'John Doe', - 'Introducción al Trading', - CURRENT_DATE, - 92.50 -); --- El número de certificado y código de verificación se generan automáticamente -``` - -### Agregar review a curso - -```sql -INSERT INTO education.course_reviews ( - user_id, - course_id, - enrollment_id, - rating, - title, - content -) VALUES ( - 'user-uuid', - 'course-uuid', - 'enrollment-uuid', - 5, - 'Excelente curso', - 'Muy bien explicado y con ejemplos prácticos' -); -``` - ---- - -## Notas Importantes - -1. **Referencias**: Todas las FKs a usuarios usan `auth.users(id)` -2. **Cascadas**: Las eliminaciones en CASCADE están definidas donde corresponde -3. **Índices**: Creados para optimizar queries frecuentes -4. **Constraints**: Validaciones de lógica de negocio implementadas -5. **JSONB**: Usado para metadata flexible (attachments, user_answers, etc.) -6. **Denormalización**: Algunas estadísticas están denormalizadas para performance - ---- - -## Mantenimiento - -### Resetear XP semanal/mensual - -```sql --- Resetear XP semanal (ejecutar cada lunes) -UPDATE education.user_gamification_profile SET weekly_xp = 0; - --- Resetear XP mensual (ejecutar el 1ro de cada mes) -UPDATE education.user_gamification_profile SET monthly_xp = 0; -``` - -### Recalcular estadísticas de curso - -```sql --- Recalcular total de módulos y lecciones -UPDATE education.courses c -SET - total_modules = (SELECT COUNT(*) FROM education.modules WHERE course_id = c.id), - total_lessons = ( - SELECT COUNT(*) - FROM education.lessons l - JOIN education.modules m ON l.module_id = m.id - WHERE m.course_id = c.id - ); -``` - ---- - -## Versión - -**Versión:** 1.0.0 -**Última actualización:** 2025-12-06 diff --git a/apps/database/ddl/schemas/education/TECHNICAL.md b/apps/database/ddl/schemas/education/TECHNICAL.md deleted file mode 100644 index 032c91c..0000000 --- a/apps/database/ddl/schemas/education/TECHNICAL.md +++ /dev/null @@ -1,458 +0,0 @@ -# Documentación Técnica - Schema Education - -**Proyecto:** OrbiQuant IA (Trading Platform) -**Schema:** education -**PostgreSQL:** 15+ -**Versión:** 1.0.0 - ---- - -## Estadísticas del Schema - -- **ENUMs:** 6 tipos -- **Tablas:** 14 tablas -- **Funciones:** 8 funciones -- **Triggers:** 15+ triggers -- **Vistas:** 7 vistas -- **Índices:** 60+ índices -- **Total líneas SQL:** ~1,350 líneas - ---- - -## Índices por Tabla - -### categories -- `idx_categories_parent` - parent_id -- `idx_categories_slug` - slug -- `idx_categories_active` - is_active (WHERE is_active = true) - -### courses -- `idx_courses_category` - category_id -- `idx_courses_slug` - slug -- `idx_courses_status` - status -- `idx_courses_difficulty` - difficulty_level -- `idx_courses_instructor` - instructor_id -- `idx_courses_published` - published_at (WHERE status = 'published') - -### modules -- `idx_modules_course` - course_id -- `idx_modules_order` - course_id, display_order - -### lessons -- `idx_lessons_module` - module_id -- `idx_lessons_order` - module_id, display_order -- `idx_lessons_type` - content_type -- `idx_lessons_preview` - is_preview (WHERE is_preview = true) - -### enrollments -- `idx_enrollments_user` - user_id -- `idx_enrollments_course` - course_id -- `idx_enrollments_status` - status -- `idx_enrollments_user_active` - user_id, status (WHERE status = 'active') - -### progress -- `idx_progress_user` - user_id -- `idx_progress_lesson` - lesson_id -- `idx_progress_enrollment` - enrollment_id -- `idx_progress_completed` - is_completed (WHERE is_completed = true) -- `idx_progress_user_enrollment` - user_id, enrollment_id - -### quizzes -- `idx_quizzes_module` - module_id -- `idx_quizzes_lesson` - lesson_id -- `idx_quizzes_active` - is_active (WHERE is_active = true) - -### quiz_questions -- `idx_quiz_questions_quiz` - quiz_id -- `idx_quiz_questions_order` - quiz_id, display_order - -### quiz_attempts -- `idx_quiz_attempts_user` - user_id -- `idx_quiz_attempts_quiz` - quiz_id -- `idx_quiz_attempts_enrollment` - enrollment_id -- `idx_quiz_attempts_user_quiz` - user_id, quiz_id -- `idx_quiz_attempts_completed` - is_completed, completed_at - -### certificates -- `idx_certificates_user` - user_id -- `idx_certificates_course` - course_id -- `idx_certificates_number` - certificate_number -- `idx_certificates_verification` - verification_code - -### user_achievements -- `idx_user_achievements_user` - user_id -- `idx_user_achievements_type` - achievement_type -- `idx_user_achievements_earned` - earned_at DESC -- `idx_user_achievements_course` - course_id - -### user_gamification_profile -- `idx_gamification_user` - user_id -- `idx_gamification_level` - current_level DESC -- `idx_gamification_xp` - total_xp DESC -- `idx_gamification_weekly` - weekly_xp DESC -- `idx_gamification_monthly` - monthly_xp DESC - -### user_activity_log -- `idx_activity_user` - user_id -- `idx_activity_type` - activity_type -- `idx_activity_created` - created_at DESC -- `idx_activity_user_date` - user_id, created_at DESC -- `idx_activity_course` - course_id (WHERE course_id IS NOT NULL) - -### course_reviews -- `idx_reviews_course` - course_id -- `idx_reviews_user` - user_id -- `idx_reviews_rating` - rating -- `idx_reviews_approved` - is_approved (WHERE is_approved = true) -- `idx_reviews_featured` - is_featured (WHERE is_featured = true) -- `idx_reviews_helpful` - helpful_votes DESC - ---- - -## Constraints - -### CHECK Constraints - -**categories:** -- `valid_color_format` - Color debe ser formato #RRGGBB - -**courses:** -- `valid_rating` - avg_rating >= 0 AND <= 5 -- `valid_price` - price_usd >= 0 - -**lessons:** -- `video_fields_required` - Si content_type='video', video_url y video_duration_seconds requeridos - -**enrollments:** -- `valid_progress` - progress_percentage >= 0 AND <= 100 -- `valid_completion` - Si status='completed', completed_at y progress=100 requeridos - -**progress:** -- `valid_watch_percentage` - watch_percentage >= 0 AND <= 100 -- `completion_requires_date` - Si is_completed=true, completed_at requerido - -**quizzes:** -- `valid_passing_score` - passing_score_percentage > 0 AND <= 100 -- `quiz_association` - Debe tener module_id O lesson_id (no ambos) - -**quiz_questions:** -- `valid_options` - Si question_type requiere options, options no puede ser NULL - -**quiz_attempts:** -- `valid_score_percentage` - score_percentage >= 0 AND <= 100 - -**user_gamification_profile:** -- `valid_level` - current_level >= 1 -- `valid_xp` - total_xp >= 0 -- `valid_streak` - current_streak_days >= 0 AND longest_streak_days >= 0 -- `valid_avg_score` - average_quiz_score >= 0 AND <= 100 - -**course_reviews:** -- `rating` - rating >= 1 AND <= 5 - -### UNIQUE Constraints - -- `categories.slug` - UNIQUE -- `courses.slug` - UNIQUE -- `modules.unique_course_order` - UNIQUE(course_id, display_order) -- `lessons.unique_module_order` - UNIQUE(module_id, display_order) -- `enrollments.unique_user_course` - UNIQUE(user_id, course_id) -- `progress.unique_user_lesson` - UNIQUE(user_id, lesson_id) -- `certificates.certificate_number` - UNIQUE -- `certificates.verification_code` - UNIQUE -- `certificates.unique_user_course_cert` - UNIQUE(user_id, course_id) -- `user_gamification_profile.unique_user_gamification` - UNIQUE(user_id) -- `course_reviews.unique_user_course_review` - UNIQUE(user_id, course_id) - ---- - -## Foreign Keys - -### Relaciones con auth.users -- `courses.instructor_id` → `auth.users(id)` ON DELETE RESTRICT -- `enrollments.user_id` → `auth.users(id)` ON DELETE CASCADE -- `progress.user_id` → `auth.users(id)` ON DELETE CASCADE -- `quiz_attempts.user_id` → `auth.users(id)` ON DELETE CASCADE -- `certificates.user_id` → `auth.users(id)` ON DELETE CASCADE -- `user_achievements.user_id` → `auth.users(id)` ON DELETE CASCADE -- `user_gamification_profile.user_id` → `auth.users(id)` ON DELETE CASCADE -- `user_activity_log.user_id` → `auth.users(id)` ON DELETE CASCADE -- `course_reviews.user_id` → `auth.users(id)` ON DELETE CASCADE -- `course_reviews.approved_by` → `auth.users(id)` - -### Relaciones internas -- `categories.parent_id` → `categories(id)` ON DELETE SET NULL -- `courses.category_id` → `categories(id)` ON DELETE RESTRICT -- `modules.course_id` → `courses(id)` ON DELETE CASCADE -- `modules.unlock_after_module_id` → `modules(id)` ON DELETE SET NULL -- `lessons.module_id` → `modules(id)` ON DELETE CASCADE -- `enrollments.course_id` → `courses(id)` ON DELETE RESTRICT -- `progress.lesson_id` → `lessons(id)` ON DELETE CASCADE -- `progress.enrollment_id` → `enrollments(id)` ON DELETE CASCADE -- `quizzes.module_id` → `modules(id)` ON DELETE CASCADE -- `quizzes.lesson_id` → `lessons(id)` ON DELETE CASCADE -- `quiz_questions.quiz_id` → `quizzes(id)` ON DELETE CASCADE -- `quiz_attempts.quiz_id` → `quizzes(id)` ON DELETE RESTRICT -- `quiz_attempts.enrollment_id` → `enrollments(id)` ON DELETE SET NULL -- `certificates.course_id` → `courses(id)` ON DELETE RESTRICT -- `certificates.enrollment_id` → `enrollments(id)` ON DELETE RESTRICT -- `user_achievements.course_id` → `courses(id)` ON DELETE SET NULL -- `user_achievements.quiz_id` → `quizzes(id)` ON DELETE SET NULL -- `user_activity_log.course_id` → `courses(id)` ON DELETE SET NULL -- `user_activity_log.lesson_id` → `lessons(id)` ON DELETE SET NULL -- `user_activity_log.quiz_id` → `quizzes(id)` ON DELETE SET NULL -- `course_reviews.course_id` → `courses(id)` ON DELETE CASCADE -- `course_reviews.enrollment_id` → `enrollments(id)` ON DELETE CASCADE - ---- - -## Triggers - -### Triggers de updated_at -Aplica a: categories, courses, modules, lessons, enrollments, progress, quizzes, quiz_questions, user_gamification_profile, course_reviews - -**Función:** `education.update_updated_at_column()` -**Trigger:** `update_{table}_updated_at` -**Evento:** BEFORE UPDATE -**Acción:** Actualiza `updated_at = NOW()` - -### Triggers de lógica de negocio - -**update_enrollment_on_progress** -- Tabla: progress -- Función: `education.update_enrollment_progress()` -- Evento: AFTER INSERT OR UPDATE -- Condición: WHEN (NEW.is_completed = true) -- Acción: Recalcula progreso del enrollment - -**auto_complete_enrollment_trigger** -- Tabla: enrollments -- Función: `education.auto_complete_enrollment()` -- Evento: BEFORE UPDATE -- Acción: Completa enrollment si progress >= 100% - -**generate_certificate_number_trigger** -- Tabla: certificates -- Función: `education.generate_certificate_number()` -- Evento: BEFORE INSERT -- Acción: Genera certificate_number y verification_code - -**update_course_rating_on_review_insert** -- Tabla: course_reviews -- Función: `education.update_course_rating_stats()` -- Evento: AFTER INSERT -- Acción: Actualiza avg_rating del curso - -**update_course_rating_on_review_update** -- Tabla: course_reviews -- Función: `education.update_course_rating_stats()` -- Evento: AFTER UPDATE -- Condición: rating o is_approved cambió -- Acción: Actualiza avg_rating del curso - -**update_course_rating_on_review_delete** -- Tabla: course_reviews -- Función: `education.update_course_rating_stats()` -- Evento: AFTER DELETE -- Acción: Actualiza avg_rating del curso - -**update_enrollment_count_on_insert** -- Tabla: enrollments -- Función: `education.update_enrollment_count()` -- Evento: AFTER INSERT -- Acción: Incrementa contador en courses - -**update_enrollment_count_on_delete** -- Tabla: enrollments -- Función: `education.update_enrollment_count()` -- Evento: AFTER DELETE -- Acción: Decrementa contador en courses - -**update_streak_on_activity** -- Tabla: user_activity_log -- Función: `education.trigger_update_streak()` -- Evento: AFTER INSERT -- Acción: Actualiza streak del usuario - ---- - -## Funciones Públicas - -### education.update_user_xp(user_id UUID, xp_to_add INTEGER) -Actualiza XP del usuario y recalcula nivel. - -**Parámetros:** -- `user_id`: UUID del usuario -- `xp_to_add`: Cantidad de XP a agregar - -**Lógica:** -- Suma XP al total -- Calcula nuevo nivel basado en fórmula cuadrática -- Actualiza weekly_xp y monthly_xp -- Crea achievement si subió de nivel - -**Ejemplo:** -```sql -SELECT education.update_user_xp( - '00000000-0000-0000-0000-000000000001', - 100 -); -``` - -### education.update_user_streak(user_id UUID) -Actualiza streak del usuario basado en actividad diaria. - -**Parámetros:** -- `user_id`: UUID del usuario - -**Lógica:** -- Verifica última actividad -- Incrementa streak si es día consecutivo -- Resetea streak si se rompió -- Crea achievement en milestones (7, 30, 100 días) - -**Ejemplo:** -```sql -SELECT education.update_user_streak( - '00000000-0000-0000-0000-000000000001' -); -``` - ---- - -## Vistas Materializadas Recomendadas - -Para mejorar performance en queries frecuentes: - -```sql --- Top cursos por enrollments (actualizar diariamente) -CREATE MATERIALIZED VIEW education.mv_top_courses AS -SELECT * FROM education.v_popular_courses; - -CREATE UNIQUE INDEX ON education.mv_top_courses(id); - --- Leaderboards (actualizar cada hora) -CREATE MATERIALIZED VIEW education.mv_leaderboard_weekly AS -SELECT * FROM education.v_leaderboard_weekly; - -CREATE UNIQUE INDEX ON education.mv_leaderboard_weekly(user_id); -``` - ---- - -## Optimizaciones Recomendadas - -### 1. Particionamiento de user_activity_log - -Para logs con alto volumen: - -```sql --- Particionar por mes -CREATE TABLE education.user_activity_log_2025_12 - PARTITION OF education.user_activity_log - FOR VALUES FROM ('2025-12-01') TO ('2026-01-01'); -``` - -### 2. Índices adicionales según uso - -```sql --- Si hay muchas búsquedas por título de curso -CREATE INDEX idx_courses_title_trgm ON education.courses - USING gin(title gin_trgm_ops); - --- Requiere extension pg_trgm -CREATE EXTENSION IF NOT EXISTS pg_trgm; -``` - -### 3. Vacuum y Analyze automático - -```sql --- Configurar autovacuum para tablas con alta escritura -ALTER TABLE education.user_activity_log - SET (autovacuum_vacuum_scale_factor = 0.01); - -ALTER TABLE education.progress - SET (autovacuum_vacuum_scale_factor = 0.02); -``` - ---- - -## Monitoreo - -### Queries útiles para monitoreo - -**Tamaño de tablas:** -```sql -SELECT - schemaname, - tablename, - pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size -FROM pg_tables -WHERE schemaname = 'education' -ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC; -``` - -**Índices no usados:** -```sql -SELECT - schemaname, - tablename, - indexname, - idx_scan -FROM pg_stat_user_indexes -WHERE schemaname = 'education' AND idx_scan = 0; -``` - -**Actividad de enrollments hoy:** -```sql -SELECT COUNT(*) -FROM education.enrollments -WHERE enrolled_at::date = CURRENT_DATE; -``` - -**Cursos más populares (últimos 7 días):** -```sql -SELECT - c.title, - COUNT(e.id) as new_enrollments -FROM education.courses c -LEFT JOIN education.enrollments e ON c.id = e.course_id - AND e.enrolled_at >= NOW() - INTERVAL '7 days' -GROUP BY c.id, c.title -ORDER BY new_enrollments DESC -LIMIT 10; -``` - ---- - -## Backup y Restore - -### Backup solo del schema education - -```bash -pg_dump -h localhost -U postgres -n education orbiquant > education_backup.sql -``` - -### Restore - -```bash -psql -h localhost -U postgres orbiquant < education_backup.sql -``` - ---- - -## Versión y Changelog - -**v1.0.0** (2025-12-06) -- Implementación inicial completa -- 14 tablas -- 8 funciones -- 7 vistas -- Sistema de gamificación completo -- Reviews de cursos -- Activity logging - ---- - -**Documentación generada:** 2025-12-06 -**Última revisión:** 2025-12-06 diff --git a/apps/database/ddl/schemas/education/functions/01-update_updated_at.sql b/apps/database/ddl/schemas/education/functions/01-update_updated_at.sql deleted file mode 100644 index 896e37d..0000000 --- a/apps/database/ddl/schemas/education/functions/01-update_updated_at.sql +++ /dev/null @@ -1,69 +0,0 @@ --- ===================================================== --- FUNCTION: education.update_updated_at_column() --- ===================================================== --- Proyecto: OrbiQuant IA (Trading Platform) --- Módulo: OQI-002 - Education --- Especificación: ET-EDU-001-database.md --- Descripción: Actualiza automáticamente el campo updated_at --- ===================================================== - -CREATE OR REPLACE FUNCTION education.update_updated_at_column() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = NOW(); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION education.update_updated_at_column() IS 'Actualiza automáticamente updated_at en cada UPDATE'; - --- Aplicar trigger a todas las tablas relevantes -CREATE TRIGGER update_categories_updated_at - BEFORE UPDATE ON education.categories - FOR EACH ROW - EXECUTE FUNCTION education.update_updated_at_column(); - -CREATE TRIGGER update_courses_updated_at - BEFORE UPDATE ON education.courses - FOR EACH ROW - EXECUTE FUNCTION education.update_updated_at_column(); - -CREATE TRIGGER update_modules_updated_at - BEFORE UPDATE ON education.modules - FOR EACH ROW - EXECUTE FUNCTION education.update_updated_at_column(); - -CREATE TRIGGER update_lessons_updated_at - BEFORE UPDATE ON education.lessons - FOR EACH ROW - EXECUTE FUNCTION education.update_updated_at_column(); - -CREATE TRIGGER update_enrollments_updated_at - BEFORE UPDATE ON education.enrollments - FOR EACH ROW - EXECUTE FUNCTION education.update_updated_at_column(); - -CREATE TRIGGER update_progress_updated_at - BEFORE UPDATE ON education.progress - FOR EACH ROW - EXECUTE FUNCTION education.update_updated_at_column(); - -CREATE TRIGGER update_quizzes_updated_at - BEFORE UPDATE ON education.quizzes - FOR EACH ROW - EXECUTE FUNCTION education.update_updated_at_column(); - -CREATE TRIGGER update_quiz_questions_updated_at - BEFORE UPDATE ON education.quiz_questions - FOR EACH ROW - EXECUTE FUNCTION education.update_updated_at_column(); - -CREATE TRIGGER update_user_gamification_profile_updated_at - BEFORE UPDATE ON education.user_gamification_profile - FOR EACH ROW - EXECUTE FUNCTION education.update_updated_at_column(); - -CREATE TRIGGER update_course_reviews_updated_at - BEFORE UPDATE ON education.course_reviews - FOR EACH ROW - EXECUTE FUNCTION education.update_updated_at_column(); diff --git a/apps/database/ddl/schemas/education/functions/02-update_enrollment_progress.sql b/apps/database/ddl/schemas/education/functions/02-update_enrollment_progress.sql deleted file mode 100644 index cffd831..0000000 --- a/apps/database/ddl/schemas/education/functions/02-update_enrollment_progress.sql +++ /dev/null @@ -1,57 +0,0 @@ --- ===================================================== --- FUNCTION: education.update_enrollment_progress() --- ===================================================== --- Proyecto: OrbiQuant IA (Trading Platform) --- Módulo: OQI-002 - Education --- Especificación: ET-EDU-001-database.md --- Descripción: Actualiza el progreso del enrollment cuando se completa una lección --- ===================================================== - -CREATE OR REPLACE FUNCTION education.update_enrollment_progress() -RETURNS TRIGGER AS $$ -DECLARE - v_total_lessons INTEGER; - v_completed_lessons INTEGER; - v_progress_percentage DECIMAL(5,2); -BEGIN - -- Obtener total de lecciones obligatorias del curso - SELECT COUNT(*) - INTO v_total_lessons - FROM education.lessons l - JOIN education.modules m ON l.module_id = m.id - JOIN education.courses c ON m.course_id = c.id - WHERE c.id = ( - SELECT course_id FROM education.enrollments WHERE id = NEW.enrollment_id - ) AND l.is_mandatory = true; - - -- Obtener lecciones completadas - SELECT COUNT(*) - INTO v_completed_lessons - FROM education.progress - WHERE enrollment_id = NEW.enrollment_id - AND is_completed = true; - - -- Calcular porcentaje de progreso - v_progress_percentage := (v_completed_lessons::DECIMAL / NULLIF(v_total_lessons, 0)::DECIMAL) * 100; - - -- Actualizar enrollment - UPDATE education.enrollments - SET - progress_percentage = COALESCE(v_progress_percentage, 0), - completed_lessons = v_completed_lessons, - total_lessons = v_total_lessons, - updated_at = NOW() - WHERE id = NEW.enrollment_id; - - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION education.update_enrollment_progress() IS 'Actualiza progreso del enrollment al completar lecciones'; - --- Trigger para actualizar el progreso -CREATE TRIGGER update_enrollment_on_progress - AFTER INSERT OR UPDATE ON education.progress - FOR EACH ROW - WHEN (NEW.is_completed = true) - EXECUTE FUNCTION education.update_enrollment_progress(); diff --git a/apps/database/ddl/schemas/education/functions/03-auto_complete_enrollment.sql b/apps/database/ddl/schemas/education/functions/03-auto_complete_enrollment.sql deleted file mode 100644 index db0e68c..0000000 --- a/apps/database/ddl/schemas/education/functions/03-auto_complete_enrollment.sql +++ /dev/null @@ -1,29 +0,0 @@ --- ===================================================== --- FUNCTION: education.auto_complete_enrollment() --- ===================================================== --- Proyecto: OrbiQuant IA (Trading Platform) --- Módulo: OQI-002 - Education --- Especificación: ET-EDU-001-database.md --- Descripción: Completa automáticamente el enrollment cuando alcanza 100% --- ===================================================== - -CREATE OR REPLACE FUNCTION education.auto_complete_enrollment() -RETURNS TRIGGER AS $$ -BEGIN - -- Si el progreso llegó al 100% y está activo, completarlo - IF NEW.progress_percentage >= 100 AND NEW.status = 'active' THEN - NEW.status := 'completed'; - NEW.completed_at := NOW(); - END IF; - - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION education.auto_complete_enrollment() IS 'Completa automáticamente el enrollment al alcanzar 100%'; - --- Trigger para auto-completar enrollment -CREATE TRIGGER auto_complete_enrollment_trigger - BEFORE UPDATE ON education.enrollments - FOR EACH ROW - EXECUTE FUNCTION education.auto_complete_enrollment(); diff --git a/apps/database/ddl/schemas/education/functions/04-generate_certificate.sql b/apps/database/ddl/schemas/education/functions/04-generate_certificate.sql deleted file mode 100644 index b1170e4..0000000 --- a/apps/database/ddl/schemas/education/functions/04-generate_certificate.sql +++ /dev/null @@ -1,47 +0,0 @@ --- ===================================================== --- FUNCTION: education.generate_certificate_number() --- ===================================================== --- Proyecto: OrbiQuant IA (Trading Platform) --- Módulo: OQI-002 - Education --- Especificación: ET-EDU-001-database.md --- Descripción: Genera automáticamente el número de certificado y código de verificación --- ===================================================== - -CREATE OR REPLACE FUNCTION education.generate_certificate_number() -RETURNS TRIGGER AS $$ -DECLARE - v_year INTEGER; - v_sequence INTEGER; -BEGIN - v_year := EXTRACT(YEAR FROM NOW()); - - -- Obtener siguiente número de secuencia para el año - SELECT COALESCE(MAX( - CAST(SUBSTRING(certificate_number FROM 14) AS INTEGER) - ), 0) + 1 - INTO v_sequence - FROM education.certificates - WHERE certificate_number LIKE 'OQI-CERT-' || v_year || '-%'; - - -- Generar número de certificado: OQI-CERT-2025-00001 - NEW.certificate_number := FORMAT('OQI-CERT-%s-%s', - v_year, - LPAD(v_sequence::TEXT, 5, '0') - ); - - -- Generar código de verificación único - NEW.verification_code := UPPER( - SUBSTRING(MD5(RANDOM()::TEXT || NOW()::TEXT) FROM 1 FOR 16) - ); - - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION education.generate_certificate_number() IS 'Genera número de certificado y código de verificación automáticamente'; - --- Trigger para generar número de certificado -CREATE TRIGGER generate_certificate_number_trigger - BEFORE INSERT ON education.certificates - FOR EACH ROW - EXECUTE FUNCTION education.generate_certificate_number(); diff --git a/apps/database/ddl/schemas/education/functions/05-update_course_stats.sql b/apps/database/ddl/schemas/education/functions/05-update_course_stats.sql deleted file mode 100644 index c52aef5..0000000 --- a/apps/database/ddl/schemas/education/functions/05-update_course_stats.sql +++ /dev/null @@ -1,59 +0,0 @@ --- ===================================================== --- FUNCTION: education.update_course_stats() --- ===================================================== --- Proyecto: OrbiQuant IA (Trading Platform) --- Módulo: OQI-002 - Education --- Especificación: ET-EDU-001-database.md --- Descripción: Actualiza estadísticas denormalizadas del curso --- ===================================================== - --- Función para actualizar estadísticas de reviews -CREATE OR REPLACE FUNCTION education.update_course_rating_stats() -RETURNS TRIGGER AS $$ -DECLARE - v_course_id UUID; - v_avg_rating DECIMAL(3,2); - v_total_reviews INTEGER; -BEGIN - -- Obtener course_id del NEW o OLD record - v_course_id := COALESCE(NEW.course_id, OLD.course_id); - - -- Calcular promedio solo de reviews aprobadas - SELECT - COALESCE(AVG(rating), 0), - COUNT(*) - INTO v_avg_rating, v_total_reviews - FROM education.course_reviews - WHERE course_id = v_course_id - AND is_approved = true; - - -- Actualizar estadísticas en el curso - UPDATE education.courses - SET - avg_rating = v_avg_rating, - total_reviews = v_total_reviews, - updated_at = NOW() - WHERE id = v_course_id; - - RETURN COALESCE(NEW, OLD); -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION education.update_course_rating_stats() IS 'Actualiza avg_rating y total_reviews del curso'; - --- Triggers para actualizar estadísticas -CREATE TRIGGER update_course_rating_on_review_insert - AFTER INSERT ON education.course_reviews - FOR EACH ROW - EXECUTE FUNCTION education.update_course_rating_stats(); - -CREATE TRIGGER update_course_rating_on_review_update - AFTER UPDATE ON education.course_reviews - FOR EACH ROW - WHEN (OLD.rating IS DISTINCT FROM NEW.rating OR OLD.is_approved IS DISTINCT FROM NEW.is_approved) - EXECUTE FUNCTION education.update_course_rating_stats(); - -CREATE TRIGGER update_course_rating_on_review_delete - AFTER DELETE ON education.course_reviews - FOR EACH ROW - EXECUTE FUNCTION education.update_course_rating_stats(); diff --git a/apps/database/ddl/schemas/education/functions/06-update_enrollment_count.sql b/apps/database/ddl/schemas/education/functions/06-update_enrollment_count.sql deleted file mode 100644 index b386cb0..0000000 --- a/apps/database/ddl/schemas/education/functions/06-update_enrollment_count.sql +++ /dev/null @@ -1,41 +0,0 @@ --- ===================================================== --- FUNCTION: education.update_enrollment_count() --- ===================================================== --- Proyecto: OrbiQuant IA (Trading Platform) --- Módulo: OQI-002 - Education --- Especificación: ET-EDU-001-database.md --- Descripción: Actualiza el contador de enrollments en courses --- ===================================================== - -CREATE OR REPLACE FUNCTION education.update_enrollment_count() -RETURNS TRIGGER AS $$ -DECLARE - v_course_id UUID; -BEGIN - v_course_id := COALESCE(NEW.course_id, OLD.course_id); - - -- Actualizar contador de enrollments - UPDATE education.courses - SET - total_enrollments = ( - SELECT COUNT(*) FROM education.enrollments WHERE course_id = v_course_id - ), - updated_at = NOW() - WHERE id = v_course_id; - - RETURN COALESCE(NEW, OLD); -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION education.update_enrollment_count() IS 'Actualiza total_enrollments del curso'; - --- Triggers para actualizar contador -CREATE TRIGGER update_enrollment_count_on_insert - AFTER INSERT ON education.enrollments - FOR EACH ROW - EXECUTE FUNCTION education.update_enrollment_count(); - -CREATE TRIGGER update_enrollment_count_on_delete - AFTER DELETE ON education.enrollments - FOR EACH ROW - EXECUTE FUNCTION education.update_enrollment_count(); diff --git a/apps/database/ddl/schemas/education/functions/07-update_gamification_profile.sql b/apps/database/ddl/schemas/education/functions/07-update_gamification_profile.sql deleted file mode 100644 index b99f470..0000000 --- a/apps/database/ddl/schemas/education/functions/07-update_gamification_profile.sql +++ /dev/null @@ -1,158 +0,0 @@ --- ===================================================== --- FUNCTION: education.update_gamification_profile() --- ===================================================== --- Proyecto: OrbiQuant IA (Trading Platform) --- Módulo: OQI-002 - Education --- Especificación: Función adicional para gamificación --- Descripción: Actualiza el perfil de gamificación del usuario --- ===================================================== - --- Función para actualizar XP y nivel -CREATE OR REPLACE FUNCTION education.update_user_xp( - p_user_id UUID, - p_xp_to_add INTEGER -) -RETURNS VOID AS $$ -DECLARE - v_profile RECORD; - v_new_total_xp INTEGER; - v_new_level INTEGER; - v_xp_to_next INTEGER; -BEGIN - -- Obtener o crear perfil - INSERT INTO education.user_gamification_profile (user_id) - VALUES (p_user_id) - ON CONFLICT (user_id) DO NOTHING; - - -- Obtener perfil actual - SELECT * INTO v_profile - FROM education.user_gamification_profile - WHERE user_id = p_user_id; - - -- Calcular nuevo XP total - v_new_total_xp := v_profile.total_xp + p_xp_to_add; - - -- Calcular nuevo nivel (cada nivel requiere 100 XP más que el anterior) - -- Nivel 1: 0-99 XP, Nivel 2: 100-299 XP, Nivel 3: 300-599 XP, etc. - v_new_level := FLOOR((-100 + SQRT(10000 + 800 * v_new_total_xp)) / 200) + 1; - - -- XP necesario para siguiente nivel - v_xp_to_next := (v_new_level * 100 + (v_new_level * (v_new_level - 1) * 100)) - v_new_total_xp; - - -- Actualizar perfil - UPDATE education.user_gamification_profile - SET - total_xp = v_new_total_xp, - current_level = v_new_level, - xp_to_next_level = v_xp_to_next, - weekly_xp = weekly_xp + p_xp_to_add, - monthly_xp = monthly_xp + p_xp_to_add, - last_activity_date = CURRENT_DATE, - updated_at = NOW() - WHERE user_id = p_user_id; - - -- Si subió de nivel, crear achievement - IF v_new_level > v_profile.current_level THEN - INSERT INTO education.user_achievements ( - user_id, - achievement_type, - title, - description, - xp_bonus - ) VALUES ( - p_user_id, - 'level_up', - 'Level Up! - Nivel ' || v_new_level, - 'Has alcanzado el nivel ' || v_new_level, - 50 - ); - END IF; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION education.update_user_xp(UUID, INTEGER) IS 'Actualiza XP del usuario y recalcula nivel'; - --- Función para actualizar streak -CREATE OR REPLACE FUNCTION education.update_user_streak(p_user_id UUID) -RETURNS VOID AS $$ -DECLARE - v_last_activity DATE; - v_current_streak INTEGER; - v_longest_streak INTEGER; -BEGIN - -- Obtener o crear perfil - INSERT INTO education.user_gamification_profile (user_id) - VALUES (p_user_id) - ON CONFLICT (user_id) DO NOTHING; - - -- Obtener datos actuales - SELECT last_activity_date, current_streak_days, longest_streak_days - INTO v_last_activity, v_current_streak, v_longest_streak - FROM education.user_gamification_profile - WHERE user_id = p_user_id; - - -- Actualizar streak - IF v_last_activity IS NULL THEN - -- Primera actividad - v_current_streak := 1; - ELSIF v_last_activity = CURRENT_DATE THEN - -- Ya tuvo actividad hoy, no hacer nada - RETURN; - ELSIF v_last_activity = CURRENT_DATE - INTERVAL '1 day' THEN - -- Actividad día consecutivo - v_current_streak := v_current_streak + 1; - ELSE - -- Se rompió el streak - v_current_streak := 1; - END IF; - - -- Actualizar longest streak si corresponde - IF v_current_streak > v_longest_streak THEN - v_longest_streak := v_current_streak; - - -- Crear achievement por streak milestones - IF v_current_streak IN (7, 30, 100) THEN - INSERT INTO education.user_achievements ( - user_id, - achievement_type, - title, - description, - xp_bonus, - metadata - ) VALUES ( - p_user_id, - 'streak_milestone', - 'Streak de ' || v_current_streak || ' días', - 'Has mantenido una racha de ' || v_current_streak || ' días consecutivos', - v_current_streak * 5, - jsonb_build_object('streak_days', v_current_streak) - ); - END IF; - END IF; - - -- Actualizar perfil - UPDATE education.user_gamification_profile - SET - current_streak_days = v_current_streak, - longest_streak_days = v_longest_streak, - last_activity_date = CURRENT_DATE, - updated_at = NOW() - WHERE user_id = p_user_id; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION education.update_user_streak(UUID) IS 'Actualiza streak del usuario basado en actividad diaria'; - --- Trigger para actualizar streak en actividades -CREATE OR REPLACE FUNCTION education.trigger_update_streak() -RETURNS TRIGGER AS $$ -BEGIN - PERFORM education.update_user_streak(NEW.user_id); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER update_streak_on_activity - AFTER INSERT ON education.user_activity_log - FOR EACH ROW - EXECUTE FUNCTION education.trigger_update_streak(); diff --git a/apps/database/ddl/schemas/education/functions/08-views.sql b/apps/database/ddl/schemas/education/functions/08-views.sql deleted file mode 100644 index e6a99a0..0000000 --- a/apps/database/ddl/schemas/education/functions/08-views.sql +++ /dev/null @@ -1,142 +0,0 @@ --- ===================================================== --- VIEWS - Schema Education --- ===================================================== --- Proyecto: OrbiQuant IA (Trading Platform) --- Módulo: OQI-002 - Education --- Especificación: ET-EDU-001-database.md --- ===================================================== - --- Vista: Cursos con estadísticas completas -CREATE OR REPLACE VIEW education.v_courses_with_stats AS -SELECT - c.*, - cat.name as category_name, - cat.slug as category_slug, - COUNT(DISTINCT m.id) as modules_count, - COUNT(DISTINCT l.id) as lessons_count, - SUM(l.video_duration_seconds) as total_duration_seconds, - COUNT(DISTINCT e.id) as enrollments_count, - COUNT(DISTINCT CASE WHEN e.status = 'completed' THEN e.id END) as completions_count -FROM education.courses c -LEFT JOIN education.categories cat ON c.category_id = cat.id -LEFT JOIN education.modules m ON c.id = m.course_id -LEFT JOIN education.lessons l ON m.id = l.module_id -LEFT JOIN education.enrollments e ON c.id = e.course_id -GROUP BY c.id, cat.name, cat.slug; - -COMMENT ON VIEW education.v_courses_with_stats IS 'Cursos con estadísticas agregadas de módulos, lecciones y enrollments'; - --- Vista: Progreso del usuario por curso -CREATE OR REPLACE VIEW education.v_user_course_progress AS -SELECT - e.user_id, - e.course_id, - c.title as course_title, - c.slug as course_slug, - c.thumbnail_url, - e.status as enrollment_status, - e.progress_percentage, - e.enrolled_at, - e.completed_at, - e.total_xp_earned, - COUNT(DISTINCT p.id) as lessons_viewed, - COUNT(DISTINCT CASE WHEN p.is_completed THEN p.id END) as lessons_completed -FROM education.enrollments e -JOIN education.courses c ON e.course_id = c.id -LEFT JOIN education.progress p ON e.id = p.enrollment_id -GROUP BY e.id, e.user_id, e.course_id, c.title, c.slug, c.thumbnail_url; - -COMMENT ON VIEW education.v_user_course_progress IS 'Progreso detallado del usuario en cada curso enrollado'; - --- Vista: Leaderboard de usuarios -CREATE OR REPLACE VIEW education.v_leaderboard_weekly AS -SELECT - ugp.user_id, - ugp.weekly_xp, - ugp.current_level, - ugp.current_streak_days, - RANK() OVER (ORDER BY ugp.weekly_xp DESC) as rank -FROM education.user_gamification_profile ugp -WHERE ugp.weekly_xp > 0 -ORDER BY ugp.weekly_xp DESC -LIMIT 100; - -COMMENT ON VIEW education.v_leaderboard_weekly IS 'Top 100 usuarios por XP semanal'; - -CREATE OR REPLACE VIEW education.v_leaderboard_monthly AS -SELECT - ugp.user_id, - ugp.monthly_xp, - ugp.current_level, - ugp.current_streak_days, - RANK() OVER (ORDER BY ugp.monthly_xp DESC) as rank -FROM education.user_gamification_profile ugp -WHERE ugp.monthly_xp > 0 -ORDER BY ugp.monthly_xp DESC -LIMIT 100; - -COMMENT ON VIEW education.v_leaderboard_monthly IS 'Top 100 usuarios por XP mensual'; - -CREATE OR REPLACE VIEW education.v_leaderboard_alltime AS -SELECT - ugp.user_id, - ugp.total_xp, - ugp.current_level, - ugp.total_courses_completed, - RANK() OVER (ORDER BY ugp.total_xp DESC) as rank -FROM education.user_gamification_profile ugp -WHERE ugp.total_xp > 0 -ORDER BY ugp.total_xp DESC -LIMIT 100; - -COMMENT ON VIEW education.v_leaderboard_alltime IS 'Top 100 usuarios por XP total histórico'; - --- Vista: Estadísticas del usuario -CREATE OR REPLACE VIEW education.v_user_statistics AS -SELECT - ugp.user_id, - ugp.total_xp, - ugp.current_level, - ugp.xp_to_next_level, - ugp.current_streak_days, - ugp.longest_streak_days, - ugp.total_courses_completed, - ugp.total_lessons_completed, - ugp.total_quizzes_passed, - ugp.total_certificates_earned, - ugp.average_quiz_score, - COUNT(DISTINCT e.id) as total_enrollments, - COUNT(DISTINCT CASE WHEN e.status = 'active' THEN e.id END) as active_enrollments, - COUNT(DISTINCT ua.id) as total_achievements -FROM education.user_gamification_profile ugp -LEFT JOIN education.enrollments e ON ugp.user_id = e.user_id -LEFT JOIN education.user_achievements ua ON ugp.user_id = ua.user_id -GROUP BY ugp.user_id, ugp.total_xp, ugp.current_level, ugp.xp_to_next_level, - ugp.current_streak_days, ugp.longest_streak_days, ugp.total_courses_completed, - ugp.total_lessons_completed, ugp.total_quizzes_passed, ugp.total_certificates_earned, - ugp.average_quiz_score; - -COMMENT ON VIEW education.v_user_statistics IS 'Estadísticas completas del usuario (gamificación + progreso)'; - --- Vista: Cursos populares -CREATE OR REPLACE VIEW education.v_popular_courses AS -SELECT - c.id, - c.title, - c.slug, - c.thumbnail_url, - c.difficulty_level, - c.avg_rating, - c.total_reviews, - c.total_enrollments, - COUNT(DISTINCT e.id) as recent_enrollments_30d, - COUNT(DISTINCT CASE WHEN e.status = 'completed' THEN e.id END) as completions -FROM education.courses c -LEFT JOIN education.enrollments e ON c.id = e.course_id - AND e.enrolled_at >= NOW() - INTERVAL '30 days' -WHERE c.status = 'published' -GROUP BY c.id -ORDER BY recent_enrollments_30d DESC, c.avg_rating DESC -LIMIT 50; - -COMMENT ON VIEW education.v_popular_courses IS 'Top 50 cursos más populares (enrollments últimos 30 días)'; diff --git a/apps/database/ddl/schemas/education/install.sh b/apps/database/ddl/schemas/education/install.sh deleted file mode 100755 index ff8f184..0000000 --- a/apps/database/ddl/schemas/education/install.sh +++ /dev/null @@ -1,132 +0,0 @@ -#!/bin/bash - -# ===================================================== -# INSTALL SCRIPT - Schema Education -# ===================================================== -# Proyecto: OrbiQuant IA (Trading Platform) -# Módulo: OQI-002 - Education -# Especificación: ET-EDU-001-database.md -# ===================================================== - -set -e # Exit on error - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' # No Color - -# Configuration -DB_HOST="${DB_HOST:-localhost}" -DB_PORT="${DB_PORT:-5432}" -DB_NAME="${DB_NAME:-orbiquant}" -DB_USER="${DB_USER:-postgres}" -SCHEMA_NAME="education" - -echo -e "${GREEN}=================================================${NC}" -echo -e "${GREEN} OrbiQuant IA - Education Schema Installation${NC}" -echo -e "${GREEN}=================================================${NC}" -echo "" - -# Check if psql is available -if ! command -v psql &> /dev/null; then - echo -e "${RED}Error: psql command not found${NC}" - echo "Please install PostgreSQL client" - exit 1 -fi - -# Get script directory -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -echo "Configuration:" -echo " Database: $DB_NAME" -echo " Host: $DB_HOST:$DB_PORT" -echo " User: $DB_USER" -echo " Schema: $SCHEMA_NAME" -echo "" - -# Function to execute SQL file -execute_sql() { - local file=$1 - local description=$2 - - echo -e "${YELLOW}▶${NC} $description" - - if [ ! -f "$file" ]; then - echo -e "${RED} ✗ File not found: $file${NC}" - return 1 - fi - - if PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -f "$file" > /dev/null 2>&1; then - echo -e "${GREEN} ✓ Success${NC}" - return 0 - else - echo -e "${RED} ✗ Failed${NC}" - return 1 - fi -} - -# Create schema if not exists -echo -e "${YELLOW}▶${NC} Creating schema: $SCHEMA_NAME" -PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c "CREATE SCHEMA IF NOT EXISTS $SCHEMA_NAME;" > /dev/null 2>&1 -echo -e "${GREEN} ✓ Schema created/verified${NC}" -echo "" - -# 1. Install ENUMs -echo -e "${GREEN}[1/3] Installing ENUMs...${NC}" -execute_sql "$SCRIPT_DIR/00-enums.sql" "Creating ENUM types" -echo "" - -# 2. Install Tables -echo -e "${GREEN}[2/3] Installing Tables...${NC}" -execute_sql "$SCRIPT_DIR/tables/01-categories.sql" "Creating table: categories" -execute_sql "$SCRIPT_DIR/tables/02-courses.sql" "Creating table: courses" -execute_sql "$SCRIPT_DIR/tables/03-modules.sql" "Creating table: modules" -execute_sql "$SCRIPT_DIR/tables/04-lessons.sql" "Creating table: lessons" -execute_sql "$SCRIPT_DIR/tables/05-enrollments.sql" "Creating table: enrollments" -execute_sql "$SCRIPT_DIR/tables/06-progress.sql" "Creating table: progress" -execute_sql "$SCRIPT_DIR/tables/07-quizzes.sql" "Creating table: quizzes" -execute_sql "$SCRIPT_DIR/tables/08-quiz_questions.sql" "Creating table: quiz_questions" -execute_sql "$SCRIPT_DIR/tables/09-quiz_attempts.sql" "Creating table: quiz_attempts" -execute_sql "$SCRIPT_DIR/tables/10-certificates.sql" "Creating table: certificates" -execute_sql "$SCRIPT_DIR/tables/11-user_achievements.sql" "Creating table: user_achievements" -execute_sql "$SCRIPT_DIR/tables/12-user_gamification_profile.sql" "Creating table: user_gamification_profile" -execute_sql "$SCRIPT_DIR/tables/13-user_activity_log.sql" "Creating table: user_activity_log" -execute_sql "$SCRIPT_DIR/tables/14-course_reviews.sql" "Creating table: course_reviews" -echo "" - -# 3. Install Functions and Triggers -echo -e "${GREEN}[3/3] Installing Functions and Triggers...${NC}" -execute_sql "$SCRIPT_DIR/functions/01-update_updated_at.sql" "Creating trigger: update_updated_at" -execute_sql "$SCRIPT_DIR/functions/02-update_enrollment_progress.sql" "Creating function: update_enrollment_progress" -execute_sql "$SCRIPT_DIR/functions/03-auto_complete_enrollment.sql" "Creating function: auto_complete_enrollment" -execute_sql "$SCRIPT_DIR/functions/04-generate_certificate.sql" "Creating function: generate_certificate_number" -execute_sql "$SCRIPT_DIR/functions/05-update_course_stats.sql" "Creating function: update_course_stats" -execute_sql "$SCRIPT_DIR/functions/06-update_enrollment_count.sql" "Creating function: update_enrollment_count" -execute_sql "$SCRIPT_DIR/functions/07-update_gamification_profile.sql" "Creating functions: gamification" -execute_sql "$SCRIPT_DIR/functions/08-views.sql" "Creating views" -echo "" - -# Verify installation -echo -e "${YELLOW}▶${NC} Verifying installation..." - -TABLE_COUNT=$(PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = '$SCHEMA_NAME' AND table_type = 'BASE TABLE';" 2>/dev/null | xargs) - -if [ "$TABLE_COUNT" -eq "14" ]; then - echo -e "${GREEN} ✓ All 14 tables created successfully${NC}" -else - echo -e "${RED} ✗ Expected 14 tables, found $TABLE_COUNT${NC}" -fi - -echo "" -echo -e "${GREEN}=================================================${NC}" -echo -e "${GREEN} Installation Complete!${NC}" -echo -e "${GREEN}=================================================${NC}" -echo "" -echo "Schema '$SCHEMA_NAME' has been installed successfully." -echo "" -echo "Next steps:" -echo " 1. Review the README.md for usage examples" -echo " 2. Run seed data scripts if needed" -echo " 3. Configure Row Level Security (RLS) policies" -echo "" diff --git a/apps/database/ddl/schemas/education/seeds-example.sql b/apps/database/ddl/schemas/education/seeds-example.sql deleted file mode 100644 index b64b0d2..0000000 --- a/apps/database/ddl/schemas/education/seeds-example.sql +++ /dev/null @@ -1,238 +0,0 @@ --- ===================================================== --- SEED DATA - Schema Education (EJEMPLO) --- ===================================================== --- Proyecto: OrbiQuant IA (Trading Platform) --- Módulo: OQI-002 - Education --- Especificación: ET-EDU-001-database.md --- ===================================================== --- NOTA: Este es un archivo de ejemplo. --- Los datos reales deben ir en /apps/database/seeds/ --- ===================================================== - -BEGIN; - --- ===================================================== --- 1. CATEGORIES --- ===================================================== - -INSERT INTO education.categories (id, name, slug, description, color, display_order, is_active) VALUES -('11111111-1111-1111-1111-111111111111', 'Trading Básico', 'trading-basico', 'Fundamentos del trading y mercados financieros', '#3B82F6', 1, true), -('22222222-2222-2222-2222-222222222222', 'Análisis Técnico', 'analisis-tecnico', 'Herramientas y técnicas de análisis técnico', '#10B981', 2, true), -('33333333-3333-3333-3333-333333333333', 'Análisis Fundamental', 'analisis-fundamental', 'Evaluación fundamental de activos', '#F59E0B', 3, true), -('44444444-4444-4444-4444-444444444444', 'Gestión de Riesgo', 'gestion-riesgo', 'Estrategias de gestión de riesgo y capital', '#EF4444', 4, true), -('55555555-5555-5555-5555-555555555555', 'Trading Algorítmico', 'trading-algoritmico', 'Automatización y estrategias algorítmicas', '#8B5CF6', 5, true), -('66666666-6666-6666-6666-666666666666', 'Psicología del Trading', 'psicologia-trading', 'Aspectos psicológicos y emocionales del trading', '#EC4899', 6, true); - --- ===================================================== --- 2. COURSES --- ===================================================== --- NOTA: instructor_id debe existir en auth.users --- Para este ejemplo, usar un UUID válido de tu sistema - -INSERT INTO education.courses ( - id, - title, - slug, - short_description, - full_description, - category_id, - difficulty_level, - instructor_id, - instructor_name, - is_free, - xp_reward, - status, - published_at, - total_modules, - total_lessons -) VALUES -( - 'c1111111-1111-1111-1111-111111111111', - 'Introducción al Trading', - 'introduccion-trading', - 'Aprende los conceptos básicos del trading desde cero', - 'Este curso te enseñará los fundamentos del trading, incluyendo tipos de mercados, instrumentos financieros, y cómo realizar tus primeras operaciones de forma segura.', - '11111111-1111-1111-1111-111111111111', - 'beginner', - '00000000-0000-0000-0000-000000000001', -- Reemplazar con ID real - 'Instructor Demo', - true, - 500, - 'published', - NOW(), - 3, - 12 -); - --- ===================================================== --- 3. MODULES --- ===================================================== - -INSERT INTO education.modules (id, course_id, title, description, display_order, duration_minutes) VALUES -('m1111111-1111-1111-1111-111111111111', 'c1111111-1111-1111-1111-111111111111', 'Módulo 1: Fundamentos', 'Conceptos básicos del trading', 1, 120), -('m2222222-2222-2222-2222-222222222222', 'c1111111-1111-1111-1111-111111111111', 'Módulo 2: Mercados Financieros', 'Tipos de mercados y activos', 2, 180), -('m3333333-3333-3333-3333-333333333333', 'c1111111-1111-1111-1111-111111111111', 'Módulo 3: Primeros Pasos', 'Cómo empezar a operar', 3, 150); - --- ===================================================== --- 4. LESSONS --- ===================================================== - -INSERT INTO education.lessons ( - id, - module_id, - title, - description, - content_type, - video_url, - video_duration_seconds, - display_order, - is_preview, - xp_reward -) VALUES -( - 'l1111111-1111-1111-1111-111111111111', - 'm1111111-1111-1111-1111-111111111111', - '¿Qué es el Trading?', - 'Introducción a los conceptos básicos del trading', - 'video', - 'https://example.com/videos/lesson-1.mp4', - 900, - 1, - true, - 10 -), -( - 'l2222222-2222-2222-2222-222222222222', - 'm1111111-1111-1111-1111-111111111111', - 'Tipos de Traders', - 'Conoce los diferentes estilos de trading', - 'video', - 'https://example.com/videos/lesson-2.mp4', - 1200, - 2, - false, - 10 -), -( - 'l3333333-3333-3333-3333-333333333333', - 'm1111111-1111-1111-1111-111111111111', - 'Terminología Básica', - 'Vocabulario esencial del trading', - 'article', - NULL, - NULL, - 3, - false, - 15 -); - --- ===================================================== --- 5. QUIZZES --- ===================================================== - -INSERT INTO education.quizzes ( - id, - module_id, - title, - description, - passing_score_percentage, - max_attempts, - xp_reward, - xp_perfect_score_bonus -) VALUES -( - 'q1111111-1111-1111-1111-111111111111', - 'm1111111-1111-1111-1111-111111111111', - 'Quiz: Fundamentos del Trading', - 'Evalúa tus conocimientos sobre los conceptos básicos', - 70, - 3, - 50, - 20 -); - --- ===================================================== --- 6. QUIZ QUESTIONS --- ===================================================== - -INSERT INTO education.quiz_questions ( - id, - quiz_id, - question_text, - question_type, - options, - explanation, - points, - display_order -) VALUES -( - 'qq111111-1111-1111-1111-111111111111', - 'q1111111-1111-1111-1111-111111111111', - '¿Qué es el trading?', - 'multiple_choice', - '[ - {"id": "a", "text": "Comprar y vender activos financieros", "isCorrect": true}, - {"id": "b", "text": "Solo comprar acciones", "isCorrect": false}, - {"id": "c", "text": "Invertir a largo plazo únicamente", "isCorrect": false}, - {"id": "d", "text": "Ahorrar dinero en un banco", "isCorrect": false} - ]'::jsonb, - 'El trading implica la compra y venta de activos financieros con el objetivo de obtener ganancias a corto o mediano plazo.', - 1, - 1 -), -( - 'qq222222-2222-2222-2222-222222222222', - 'q1111111-1111-1111-1111-111111111111', - '¿El trading es una actividad de riesgo?', - 'true_false', - '[ - {"id": "true", "text": "Verdadero", "isCorrect": true}, - {"id": "false", "text": "Falso", "isCorrect": false} - ]'::jsonb, - 'Sí, el trading es una actividad que conlleva riesgos y es posible perder dinero. Por eso es importante la gestión de riesgo.', - 1, - 2 -); - --- ===================================================== --- EJEMPLO DE USO - ENROLLMENTS --- ===================================================== --- NOTA: Estos son ejemplos comentados. NO ejecutar sin IDs reales. - -/* --- Enrollar un usuario a un curso -INSERT INTO education.enrollments (user_id, course_id, total_lessons) -VALUES ( - '00000000-0000-0000-0000-000000000001', -- ID del usuario - 'c1111111-1111-1111-1111-111111111111', -- ID del curso - 12 -- Total de lecciones del curso -); - --- Registrar progreso en una lección -INSERT INTO education.progress ( - user_id, - lesson_id, - enrollment_id, - is_completed, - watch_percentage -) VALUES ( - '00000000-0000-0000-0000-000000000001', - 'l1111111-1111-1111-1111-111111111111', - '', - true, - 100.00 -); -*/ - -COMMIT; - --- ===================================================== --- Verificación --- ===================================================== - -SELECT 'Categories created:' as info, COUNT(*) as count FROM education.categories; -SELECT 'Courses created:' as info, COUNT(*) as count FROM education.courses; -SELECT 'Modules created:' as info, COUNT(*) as count FROM education.modules; -SELECT 'Lessons created:' as info, COUNT(*) as count FROM education.lessons; -SELECT 'Quizzes created:' as info, COUNT(*) as count FROM education.quizzes; -SELECT 'Questions created:' as info, COUNT(*) as count FROM education.quiz_questions; diff --git a/apps/database/ddl/schemas/education/tables/01-categories.sql b/apps/database/ddl/schemas/education/tables/01-categories.sql deleted file mode 100644 index 1d33e57..0000000 --- a/apps/database/ddl/schemas/education/tables/01-categories.sql +++ /dev/null @@ -1,42 +0,0 @@ --- ===================================================== --- TABLE: education.categories --- ===================================================== --- Proyecto: OrbiQuant IA (Trading Platform) --- Módulo: OQI-002 - Education --- Especificación: ET-EDU-001-database.md --- ===================================================== - -CREATE TABLE education.categories ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Información básica - name VARCHAR(100) NOT NULL, - slug VARCHAR(100) NOT NULL UNIQUE, - description TEXT, - - -- Jerarquía - parent_id UUID REFERENCES education.categories(id) ON DELETE SET NULL, - - -- Ordenamiento y visualización - display_order INTEGER DEFAULT 0, - icon_url VARCHAR(500), - color VARCHAR(7), -- Código hex #RRGGBB - - -- Metadata - is_active BOOLEAN DEFAULT true, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - CONSTRAINT valid_color_format CHECK (color ~ '^#[0-9A-Fa-f]{6}$') -); - --- Índices -CREATE INDEX idx_categories_parent ON education.categories(parent_id); -CREATE INDEX idx_categories_slug ON education.categories(slug); -CREATE INDEX idx_categories_active ON education.categories(is_active) WHERE is_active = true; - --- Comentarios -COMMENT ON TABLE education.categories IS 'Categorías de cursos con soporte para jerarquía'; -COMMENT ON COLUMN education.categories.parent_id IS 'Categoría padre para jerarquía'; -COMMENT ON COLUMN education.categories.display_order IS 'Orden de visualización'; -COMMENT ON COLUMN education.categories.color IS 'Color en formato hexadecimal #RRGGBB'; diff --git a/apps/database/ddl/schemas/education/tables/02-courses.sql b/apps/database/ddl/schemas/education/tables/02-courses.sql deleted file mode 100644 index 8b452f3..0000000 --- a/apps/database/ddl/schemas/education/tables/02-courses.sql +++ /dev/null @@ -1,74 +0,0 @@ --- ===================================================== --- TABLE: education.courses --- ===================================================== --- Proyecto: OrbiQuant IA (Trading Platform) --- Módulo: OQI-002 - Education --- Especificación: ET-EDU-001-database.md --- ===================================================== - -CREATE TABLE education.courses ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Información básica - title VARCHAR(200) NOT NULL, - slug VARCHAR(200) NOT NULL UNIQUE, - short_description VARCHAR(500), - full_description TEXT, - - -- Categorización - category_id UUID NOT NULL REFERENCES education.categories(id) ON DELETE RESTRICT, - difficulty_level education.difficulty_level NOT NULL DEFAULT 'beginner', - - -- Contenido - thumbnail_url VARCHAR(500), - trailer_url VARCHAR(500), -- Video de presentación - - -- Metadata educativa - duration_minutes INTEGER, -- Duración estimada total - prerequisites TEXT[], -- IDs de cursos prerequisitos - learning_objectives TEXT[], -- Array de objetivos - - -- Instructor - instructor_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT, - instructor_name VARCHAR(200), -- Denormalizado para performance - - -- Pricing (para futuras features) - is_free BOOLEAN DEFAULT true, - price_usd DECIMAL(10,2), - - -- Gamificación - xp_reward INTEGER DEFAULT 0, -- XP al completar el curso - - -- Estado - status education.course_status DEFAULT 'draft', - published_at TIMESTAMPTZ, - - -- Estadísticas (denormalizadas) - total_modules INTEGER DEFAULT 0, - total_lessons INTEGER DEFAULT 0, - total_enrollments INTEGER DEFAULT 0, - avg_rating DECIMAL(3,2) DEFAULT 0.00, - total_reviews INTEGER DEFAULT 0, - - -- Metadata - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - CONSTRAINT valid_rating CHECK (avg_rating >= 0 AND avg_rating <= 5), - CONSTRAINT valid_price CHECK (price_usd >= 0) -); - --- Índices -CREATE INDEX idx_courses_category ON education.courses(category_id); -CREATE INDEX idx_courses_slug ON education.courses(slug); -CREATE INDEX idx_courses_status ON education.courses(status); -CREATE INDEX idx_courses_difficulty ON education.courses(difficulty_level); -CREATE INDEX idx_courses_instructor ON education.courses(instructor_id); -CREATE INDEX idx_courses_published ON education.courses(published_at) WHERE status = 'published'; - --- Comentarios -COMMENT ON TABLE education.courses IS 'Cursos del módulo educativo'; -COMMENT ON COLUMN education.courses.instructor_name IS 'Denormalizado para performance en queries'; -COMMENT ON COLUMN education.courses.prerequisites IS 'Array de UUIDs de cursos prerequisitos'; -COMMENT ON COLUMN education.courses.learning_objectives IS 'Array de objetivos de aprendizaje'; -COMMENT ON COLUMN education.courses.xp_reward IS 'XP otorgado al completar el curso'; diff --git a/apps/database/ddl/schemas/education/tables/03-modules.sql b/apps/database/ddl/schemas/education/tables/03-modules.sql deleted file mode 100644 index 09ffcd2..0000000 --- a/apps/database/ddl/schemas/education/tables/03-modules.sql +++ /dev/null @@ -1,43 +0,0 @@ --- ===================================================== --- TABLE: education.modules --- ===================================================== --- Proyecto: OrbiQuant IA (Trading Platform) --- Módulo: OQI-002 - Education --- Especificación: ET-EDU-001-database.md --- ===================================================== - -CREATE TABLE education.modules ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Relación con curso - course_id UUID NOT NULL REFERENCES education.courses(id) ON DELETE CASCADE, - - -- Información básica - title VARCHAR(200) NOT NULL, - description TEXT, - - -- Ordenamiento - display_order INTEGER NOT NULL DEFAULT 0, - - -- Metadata - duration_minutes INTEGER, - - -- Control de acceso - is_locked BOOLEAN DEFAULT false, -- Requiere completar módulos anteriores - unlock_after_module_id UUID REFERENCES education.modules(id) ON DELETE SET NULL, - - -- Metadata - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - CONSTRAINT unique_course_order UNIQUE(course_id, display_order) -); - --- Índices -CREATE INDEX idx_modules_course ON education.modules(course_id); -CREATE INDEX idx_modules_order ON education.modules(course_id, display_order); - --- Comentarios -COMMENT ON TABLE education.modules IS 'Módulos que agrupan lecciones dentro de un curso'; -COMMENT ON COLUMN education.modules.is_locked IS 'Si requiere completar módulos anteriores para desbloquearse'; -COMMENT ON COLUMN education.modules.unlock_after_module_id IS 'Módulo que debe completarse antes de acceder a este'; diff --git a/apps/database/ddl/schemas/education/tables/04-lessons.sql b/apps/database/ddl/schemas/education/tables/04-lessons.sql deleted file mode 100644 index 30666e4..0000000 --- a/apps/database/ddl/schemas/education/tables/04-lessons.sql +++ /dev/null @@ -1,66 +0,0 @@ --- ===================================================== --- TABLE: education.lessons --- ===================================================== --- Proyecto: OrbiQuant IA (Trading Platform) --- Módulo: OQI-002 - Education --- Especificación: ET-EDU-001-database.md --- ===================================================== - -CREATE TABLE education.lessons ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Relación con módulo - module_id UUID NOT NULL REFERENCES education.modules(id) ON DELETE CASCADE, - - -- Información básica - title VARCHAR(200) NOT NULL, - description TEXT, - - -- Tipo de contenido - content_type education.lesson_content_type NOT NULL DEFAULT 'video', - - -- Contenido video - video_url VARCHAR(500), -- URL de Vimeo/S3 - video_duration_seconds INTEGER, - video_provider VARCHAR(50), -- 'vimeo', 's3', etc. - video_id VARCHAR(200), -- ID del video en el provider - - -- Contenido texto/article - article_content TEXT, - - -- Recursos adicionales - attachments JSONB, -- [{name, url, type, size}] - - -- Ordenamiento - display_order INTEGER NOT NULL DEFAULT 0, - - -- Configuración - is_preview BOOLEAN DEFAULT false, -- Puede verse sin enrollment - is_mandatory BOOLEAN DEFAULT true, -- Requerido para completar el curso - - -- Gamificación - xp_reward INTEGER DEFAULT 10, -- XP al completar la lección - - -- Metadata - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - CONSTRAINT unique_module_order UNIQUE(module_id, display_order), - CONSTRAINT video_fields_required CHECK ( - (content_type != 'video') OR - (video_url IS NOT NULL AND video_duration_seconds IS NOT NULL) - ) -); - --- Índices -CREATE INDEX idx_lessons_module ON education.lessons(module_id); -CREATE INDEX idx_lessons_order ON education.lessons(module_id, display_order); -CREATE INDEX idx_lessons_type ON education.lessons(content_type); -CREATE INDEX idx_lessons_preview ON education.lessons(is_preview) WHERE is_preview = true; - --- Comentarios -COMMENT ON TABLE education.lessons IS 'Lecciones individuales dentro de módulos'; -COMMENT ON COLUMN education.lessons.attachments IS 'Archivos adjuntos en formato JSON: [{name, url, type, size}]'; -COMMENT ON COLUMN education.lessons.is_preview IS 'Puede verse sin enrollment (preview gratuito)'; -COMMENT ON COLUMN education.lessons.is_mandatory IS 'Requerido para completar el curso'; -COMMENT ON COLUMN education.lessons.xp_reward IS 'XP otorgado al completar la lección'; diff --git a/apps/database/ddl/schemas/education/tables/05-enrollments.sql b/apps/database/ddl/schemas/education/tables/05-enrollments.sql deleted file mode 100644 index 78053f2..0000000 --- a/apps/database/ddl/schemas/education/tables/05-enrollments.sql +++ /dev/null @@ -1,56 +0,0 @@ --- ===================================================== --- TABLE: education.enrollments --- ===================================================== --- Proyecto: OrbiQuant IA (Trading Platform) --- Módulo: OQI-002 - Education --- Especificación: ET-EDU-001-database.md --- ===================================================== - -CREATE TABLE education.enrollments ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Relaciones - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - course_id UUID NOT NULL REFERENCES education.courses(id) ON DELETE RESTRICT, - - -- Estado - status education.enrollment_status DEFAULT 'active', - - -- Progreso - progress_percentage DECIMAL(5,2) DEFAULT 0.00, - completed_lessons INTEGER DEFAULT 0, - total_lessons INTEGER DEFAULT 0, -- Snapshot del total al enrollarse - - -- Fechas importantes - enrolled_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - started_at TIMESTAMPTZ, -- Primera lección vista - completed_at TIMESTAMPTZ, - expires_at TIMESTAMPTZ, -- Para cursos con límite de tiempo - - -- Gamificación - total_xp_earned INTEGER DEFAULT 0, - - -- Metadata - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - CONSTRAINT unique_user_course UNIQUE(user_id, course_id), - CONSTRAINT valid_progress CHECK (progress_percentage >= 0 AND progress_percentage <= 100), - CONSTRAINT valid_completion CHECK ( - (status != 'completed') OR - (completed_at IS NOT NULL AND progress_percentage = 100) - ) -); - --- Índices -CREATE INDEX idx_enrollments_user ON education.enrollments(user_id); -CREATE INDEX idx_enrollments_course ON education.enrollments(course_id); -CREATE INDEX idx_enrollments_status ON education.enrollments(status); -CREATE INDEX idx_enrollments_user_active ON education.enrollments(user_id, status) - WHERE status = 'active'; - --- Comentarios -COMMENT ON TABLE education.enrollments IS 'Inscripciones de usuarios a cursos'; -COMMENT ON COLUMN education.enrollments.total_lessons IS 'Snapshot del total de lecciones al momento de enrollarse'; -COMMENT ON COLUMN education.enrollments.started_at IS 'Timestamp de la primera lección vista'; -COMMENT ON COLUMN education.enrollments.expires_at IS 'Fecha de expiración para cursos con límite de tiempo'; diff --git a/apps/database/ddl/schemas/education/tables/06-progress.sql b/apps/database/ddl/schemas/education/tables/06-progress.sql deleted file mode 100644 index 2f84c8d..0000000 --- a/apps/database/ddl/schemas/education/tables/06-progress.sql +++ /dev/null @@ -1,52 +0,0 @@ --- ===================================================== --- TABLE: education.progress --- ===================================================== --- Proyecto: OrbiQuant IA (Trading Platform) --- Módulo: OQI-002 - Education --- Especificación: ET-EDU-001-database.md --- ===================================================== - -CREATE TABLE education.progress ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Relaciones - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - lesson_id UUID NOT NULL REFERENCES education.lessons(id) ON DELETE CASCADE, - enrollment_id UUID NOT NULL REFERENCES education.enrollments(id) ON DELETE CASCADE, - - -- Estado - is_completed BOOLEAN DEFAULT false, - - -- Progreso de video - last_position_seconds INTEGER DEFAULT 0, - total_watch_time_seconds INTEGER DEFAULT 0, -- Tiempo total visto - watch_percentage DECIMAL(5,2) DEFAULT 0.00, - - -- Tracking - first_viewed_at TIMESTAMPTZ, - last_viewed_at TIMESTAMPTZ, - completed_at TIMESTAMPTZ, - - -- Metadata - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - CONSTRAINT unique_user_lesson UNIQUE(user_id, lesson_id), - CONSTRAINT valid_watch_percentage CHECK (watch_percentage >= 0 AND watch_percentage <= 100), - CONSTRAINT completion_requires_date CHECK ( - (NOT is_completed) OR (completed_at IS NOT NULL) - ) -); - --- Índices -CREATE INDEX idx_progress_user ON education.progress(user_id); -CREATE INDEX idx_progress_lesson ON education.progress(lesson_id); -CREATE INDEX idx_progress_enrollment ON education.progress(enrollment_id); -CREATE INDEX idx_progress_completed ON education.progress(is_completed) WHERE is_completed = true; -CREATE INDEX idx_progress_user_enrollment ON education.progress(user_id, enrollment_id); - --- Comentarios -COMMENT ON TABLE education.progress IS 'Progreso individual del usuario en cada lección'; -COMMENT ON COLUMN education.progress.last_position_seconds IS 'Última posición del video en segundos'; -COMMENT ON COLUMN education.progress.total_watch_time_seconds IS 'Tiempo total de visualización acumulado'; -COMMENT ON COLUMN education.progress.watch_percentage IS 'Porcentaje de la lección completada'; diff --git a/apps/database/ddl/schemas/education/tables/07-quizzes.sql b/apps/database/ddl/schemas/education/tables/07-quizzes.sql deleted file mode 100644 index d5ac8a8..0000000 --- a/apps/database/ddl/schemas/education/tables/07-quizzes.sql +++ /dev/null @@ -1,57 +0,0 @@ --- ===================================================== --- TABLE: education.quizzes --- ===================================================== --- Proyecto: OrbiQuant IA (Trading Platform) --- Módulo: OQI-002 - Education --- Especificación: ET-EDU-001-database.md --- ===================================================== - -CREATE TABLE education.quizzes ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Relación (puede estar asociado a módulo o lección) - module_id UUID REFERENCES education.modules(id) ON DELETE CASCADE, - lesson_id UUID REFERENCES education.lessons(id) ON DELETE CASCADE, - - -- Información básica - title VARCHAR(200) NOT NULL, - description TEXT, - - -- Configuración - passing_score_percentage INTEGER DEFAULT 70, -- % mínimo para aprobar - max_attempts INTEGER, -- NULL = intentos ilimitados - time_limit_minutes INTEGER, -- NULL = sin límite de tiempo - - -- Opciones - shuffle_questions BOOLEAN DEFAULT true, - shuffle_answers BOOLEAN DEFAULT true, - show_correct_answers BOOLEAN DEFAULT true, -- Después de completar - - -- Gamificación - xp_reward INTEGER DEFAULT 50, - xp_perfect_score_bonus INTEGER DEFAULT 20, - - -- Estado - is_active BOOLEAN DEFAULT true, - - -- Metadata - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - CONSTRAINT valid_passing_score CHECK (passing_score_percentage > 0 AND passing_score_percentage <= 100), - CONSTRAINT quiz_association CHECK ( - (module_id IS NOT NULL AND lesson_id IS NULL) OR - (module_id IS NULL AND lesson_id IS NOT NULL) - ) -); - --- Índices -CREATE INDEX idx_quizzes_module ON education.quizzes(module_id); -CREATE INDEX idx_quizzes_lesson ON education.quizzes(lesson_id); -CREATE INDEX idx_quizzes_active ON education.quizzes(is_active) WHERE is_active = true; - --- Comentarios -COMMENT ON TABLE education.quizzes IS 'Quizzes/evaluaciones asociadas a módulos o lecciones'; -COMMENT ON COLUMN education.quizzes.max_attempts IS 'NULL = intentos ilimitados'; -COMMENT ON COLUMN education.quizzes.time_limit_minutes IS 'NULL = sin límite de tiempo'; -COMMENT ON COLUMN education.quizzes.xp_perfect_score_bonus IS 'XP bonus por obtener 100% de score'; diff --git a/apps/database/ddl/schemas/education/tables/08-quiz_questions.sql b/apps/database/ddl/schemas/education/tables/08-quiz_questions.sql deleted file mode 100644 index 76383a1..0000000 --- a/apps/database/ddl/schemas/education/tables/08-quiz_questions.sql +++ /dev/null @@ -1,56 +0,0 @@ --- ===================================================== --- TABLE: education.quiz_questions --- ===================================================== --- Proyecto: OrbiQuant IA (Trading Platform) --- Módulo: OQI-002 - Education --- Especificación: ET-EDU-001-database.md --- ===================================================== - -CREATE TABLE education.quiz_questions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Relación - quiz_id UUID NOT NULL REFERENCES education.quizzes(id) ON DELETE CASCADE, - - -- Pregunta - question_text TEXT NOT NULL, - question_type education.question_type NOT NULL DEFAULT 'multiple_choice', - - -- Opciones de respuesta (para multiple_choice, true_false, multiple_select) - options JSONB, -- [{id, text, isCorrect}] - - -- Respuesta correcta (para fill_blank, code_challenge) - correct_answer TEXT, - - -- Explicación - explanation TEXT, -- Mostrar después de responder - - -- Recursos adicionales - image_url VARCHAR(500), - code_snippet TEXT, - - -- Puntuación - points INTEGER DEFAULT 1, - - -- Ordenamiento - display_order INTEGER DEFAULT 0, - - -- Metadata - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - CONSTRAINT valid_options CHECK ( - (question_type NOT IN ('multiple_choice', 'true_false', 'multiple_select')) OR - (options IS NOT NULL) - ) -); - --- Índices -CREATE INDEX idx_quiz_questions_quiz ON education.quiz_questions(quiz_id); -CREATE INDEX idx_quiz_questions_order ON education.quiz_questions(quiz_id, display_order); - --- Comentarios -COMMENT ON TABLE education.quiz_questions IS 'Preguntas individuales de los quizzes'; -COMMENT ON COLUMN education.quiz_questions.options IS 'Array JSON de opciones: [{id, text, isCorrect}]'; -COMMENT ON COLUMN education.quiz_questions.correct_answer IS 'Respuesta correcta para fill_blank y code_challenge'; -COMMENT ON COLUMN education.quiz_questions.explanation IS 'Explicación mostrada después de responder'; diff --git a/apps/database/ddl/schemas/education/tables/09-quiz_attempts.sql b/apps/database/ddl/schemas/education/tables/09-quiz_attempts.sql deleted file mode 100644 index 626f416..0000000 --- a/apps/database/ddl/schemas/education/tables/09-quiz_attempts.sql +++ /dev/null @@ -1,53 +0,0 @@ --- ===================================================== --- TABLE: education.quiz_attempts --- ===================================================== --- Proyecto: OrbiQuant IA (Trading Platform) --- Módulo: OQI-002 - Education --- Especificación: ET-EDU-001-database.md --- ===================================================== - -CREATE TABLE education.quiz_attempts ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Relaciones - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - quiz_id UUID NOT NULL REFERENCES education.quizzes(id) ON DELETE RESTRICT, - enrollment_id UUID REFERENCES education.enrollments(id) ON DELETE SET NULL, - - -- Estado del intento - is_completed BOOLEAN DEFAULT false, - is_passed BOOLEAN DEFAULT false, - - -- Respuestas del usuario - user_answers JSONB, -- [{questionId, answer, isCorrect, points}] - - -- Puntuación - score_points INTEGER DEFAULT 0, - max_points INTEGER DEFAULT 0, - score_percentage DECIMAL(5,2) DEFAULT 0.00, - - -- Tiempo - started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - completed_at TIMESTAMPTZ, - time_taken_seconds INTEGER, - - -- XP ganado - xp_earned INTEGER DEFAULT 0, - - -- Metadata - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - CONSTRAINT valid_score_percentage CHECK (score_percentage >= 0 AND score_percentage <= 100) -); - --- Índices -CREATE INDEX idx_quiz_attempts_user ON education.quiz_attempts(user_id); -CREATE INDEX idx_quiz_attempts_quiz ON education.quiz_attempts(quiz_id); -CREATE INDEX idx_quiz_attempts_enrollment ON education.quiz_attempts(enrollment_id); -CREATE INDEX idx_quiz_attempts_user_quiz ON education.quiz_attempts(user_id, quiz_id); -CREATE INDEX idx_quiz_attempts_completed ON education.quiz_attempts(is_completed, completed_at); - --- Comentarios -COMMENT ON TABLE education.quiz_attempts IS 'Intentos de los usuarios en los quizzes'; -COMMENT ON COLUMN education.quiz_attempts.user_answers IS 'Respuestas del usuario: [{questionId, answer, isCorrect, points}]'; -COMMENT ON COLUMN education.quiz_attempts.time_taken_seconds IS 'Tiempo total empleado en segundos'; diff --git a/apps/database/ddl/schemas/education/tables/10-certificates.sql b/apps/database/ddl/schemas/education/tables/10-certificates.sql deleted file mode 100644 index c0016b6..0000000 --- a/apps/database/ddl/schemas/education/tables/10-certificates.sql +++ /dev/null @@ -1,54 +0,0 @@ --- ===================================================== --- TABLE: education.certificates --- ===================================================== --- Proyecto: OrbiQuant IA (Trading Platform) --- Módulo: OQI-002 - Education --- Especificación: ET-EDU-001-database.md --- ===================================================== - -CREATE TABLE education.certificates ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Relaciones - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - course_id UUID NOT NULL REFERENCES education.courses(id) ON DELETE RESTRICT, - enrollment_id UUID NOT NULL REFERENCES education.enrollments(id) ON DELETE RESTRICT, - - -- Información del certificado - certificate_number VARCHAR(50) NOT NULL UNIQUE, -- OQI-CERT-XXXX-YYYY - - -- Datos del certificado (snapshot) - user_name VARCHAR(200) NOT NULL, - course_title VARCHAR(200) NOT NULL, - instructor_name VARCHAR(200), - completion_date DATE NOT NULL, - - -- Metadata de logro - final_score DECIMAL(5,2), -- Promedio de quizzes - total_xp_earned INTEGER, - - -- URL del PDF generado - certificate_url VARCHAR(500), - - -- Verificación - verification_code VARCHAR(100) UNIQUE, -- Para verificar autenticidad - is_verified BOOLEAN DEFAULT true, - - -- Metadata - issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - CONSTRAINT unique_user_course_cert UNIQUE(user_id, course_id) -); - --- Índices -CREATE INDEX idx_certificates_user ON education.certificates(user_id); -CREATE INDEX idx_certificates_course ON education.certificates(course_id); -CREATE INDEX idx_certificates_number ON education.certificates(certificate_number); -CREATE INDEX idx_certificates_verification ON education.certificates(verification_code); - --- Comentarios -COMMENT ON TABLE education.certificates IS 'Certificados emitidos al completar cursos'; -COMMENT ON COLUMN education.certificates.certificate_number IS 'Número único formato: OQI-CERT-YYYY-NNNNN'; -COMMENT ON COLUMN education.certificates.verification_code IS 'Código para verificar autenticidad del certificado'; -COMMENT ON COLUMN education.certificates.final_score IS 'Promedio de todos los quizzes del curso'; diff --git a/apps/database/ddl/schemas/education/tables/11-user_achievements.sql b/apps/database/ddl/schemas/education/tables/11-user_achievements.sql deleted file mode 100644 index 2337773..0000000 --- a/apps/database/ddl/schemas/education/tables/11-user_achievements.sql +++ /dev/null @@ -1,47 +0,0 @@ --- ===================================================== --- TABLE: education.user_achievements --- ===================================================== --- Proyecto: OrbiQuant IA (Trading Platform) --- Módulo: OQI-002 - Education --- Especificación: ET-EDU-001-database.md --- ===================================================== - -CREATE TABLE education.user_achievements ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Relación - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - - -- Tipo de logro - achievement_type education.achievement_type NOT NULL, - - -- Información del logro - title VARCHAR(200) NOT NULL, - description TEXT, - badge_icon_url VARCHAR(500), - - -- Metadata del logro - metadata JSONB, -- Información específica del logro - - -- Referencias - course_id UUID REFERENCES education.courses(id) ON DELETE SET NULL, - quiz_id UUID REFERENCES education.quizzes(id) ON DELETE SET NULL, - - -- XP bonus por el logro - xp_bonus INTEGER DEFAULT 0, - - -- Metadata - earned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Índices -CREATE INDEX idx_user_achievements_user ON education.user_achievements(user_id); -CREATE INDEX idx_user_achievements_type ON education.user_achievements(achievement_type); -CREATE INDEX idx_user_achievements_earned ON education.user_achievements(earned_at DESC); -CREATE INDEX idx_user_achievements_course ON education.user_achievements(course_id); - --- Comentarios -COMMENT ON TABLE education.user_achievements IS 'Logros/badges obtenidos por los usuarios'; -COMMENT ON COLUMN education.user_achievements.metadata IS 'Información adicional específica del tipo de logro'; -COMMENT ON COLUMN education.user_achievements.xp_bonus IS 'XP bonus otorgado por este logro'; diff --git a/apps/database/ddl/schemas/education/tables/12-user_gamification_profile.sql b/apps/database/ddl/schemas/education/tables/12-user_gamification_profile.sql deleted file mode 100644 index 8b1b310..0000000 --- a/apps/database/ddl/schemas/education/tables/12-user_gamification_profile.sql +++ /dev/null @@ -1,56 +0,0 @@ --- ===================================================== --- TABLE: education.user_gamification_profile --- ===================================================== --- Proyecto: OrbiQuant IA (Trading Platform) --- Módulo: OQI-002 - Education --- Especificación: Tabla adicional para gamificación --- ===================================================== - -CREATE TABLE education.user_gamification_profile ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - - -- XP y nivel - total_xp INTEGER NOT NULL DEFAULT 0, - current_level INTEGER NOT NULL DEFAULT 1, - xp_to_next_level INTEGER NOT NULL DEFAULT 100, - - -- Streaks - current_streak_days INTEGER DEFAULT 0, - longest_streak_days INTEGER DEFAULT 0, - last_activity_date DATE, - - -- Estadísticas - total_courses_completed INTEGER DEFAULT 0, - total_lessons_completed INTEGER DEFAULT 0, - total_quizzes_passed INTEGER DEFAULT 0, - total_certificates_earned INTEGER DEFAULT 0, - average_quiz_score DECIMAL(5,2) DEFAULT 0.00, - - -- Ranking - weekly_xp INTEGER DEFAULT 0, - monthly_xp INTEGER DEFAULT 0, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - CONSTRAINT unique_user_gamification UNIQUE(user_id), - CONSTRAINT valid_level CHECK (current_level >= 1), - CONSTRAINT valid_xp CHECK (total_xp >= 0), - CONSTRAINT valid_streak CHECK (current_streak_days >= 0 AND longest_streak_days >= 0), - CONSTRAINT valid_avg_score CHECK (average_quiz_score >= 0 AND average_quiz_score <= 100) -); - --- Índices -CREATE INDEX idx_gamification_user ON education.user_gamification_profile(user_id); -CREATE INDEX idx_gamification_level ON education.user_gamification_profile(current_level DESC); -CREATE INDEX idx_gamification_xp ON education.user_gamification_profile(total_xp DESC); -CREATE INDEX idx_gamification_weekly ON education.user_gamification_profile(weekly_xp DESC); -CREATE INDEX idx_gamification_monthly ON education.user_gamification_profile(monthly_xp DESC); - --- Comentarios -COMMENT ON TABLE education.user_gamification_profile IS 'Perfil de gamificación del usuario con XP, niveles, streaks y estadísticas'; -COMMENT ON COLUMN education.user_gamification_profile.current_streak_days IS 'Días consecutivos de actividad actual'; -COMMENT ON COLUMN education.user_gamification_profile.longest_streak_days IS 'Racha más larga de días consecutivos'; -COMMENT ON COLUMN education.user_gamification_profile.weekly_xp IS 'XP acumulado en la semana actual (para leaderboards)'; -COMMENT ON COLUMN education.user_gamification_profile.monthly_xp IS 'XP acumulado en el mes actual (para leaderboards)'; diff --git a/apps/database/ddl/schemas/education/tables/13-user_activity_log.sql b/apps/database/ddl/schemas/education/tables/13-user_activity_log.sql deleted file mode 100644 index 50dad0b..0000000 --- a/apps/database/ddl/schemas/education/tables/13-user_activity_log.sql +++ /dev/null @@ -1,43 +0,0 @@ --- ===================================================== --- TABLE: education.user_activity_log --- ===================================================== --- Proyecto: OrbiQuant IA (Trading Platform) --- Módulo: OQI-002 - Education --- Especificación: Tabla adicional para tracking de actividad --- ===================================================== - -CREATE TABLE education.user_activity_log ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - - -- Tipo de actividad - activity_type VARCHAR(50) NOT NULL, -- lesson_view, quiz_complete, course_enroll, etc. - - -- Referencias opcionales - course_id UUID REFERENCES education.courses(id) ON DELETE SET NULL, - lesson_id UUID REFERENCES education.lessons(id) ON DELETE SET NULL, - quiz_id UUID REFERENCES education.quizzes(id) ON DELETE SET NULL, - - -- Metadata - metadata JSONB DEFAULT '{}', - xp_earned INTEGER DEFAULT 0, - - -- Contexto - ip_address INET, - user_agent TEXT, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Índices -CREATE INDEX idx_activity_user ON education.user_activity_log(user_id); -CREATE INDEX idx_activity_type ON education.user_activity_log(activity_type); -CREATE INDEX idx_activity_created ON education.user_activity_log(created_at DESC); -CREATE INDEX idx_activity_user_date ON education.user_activity_log(user_id, created_at DESC); -CREATE INDEX idx_activity_course ON education.user_activity_log(course_id) WHERE course_id IS NOT NULL; - --- Comentarios -COMMENT ON TABLE education.user_activity_log IS 'Log de actividades del usuario en el módulo educativo'; -COMMENT ON COLUMN education.user_activity_log.activity_type IS 'Tipos: lesson_view, lesson_complete, quiz_start, quiz_complete, course_enroll, etc.'; -COMMENT ON COLUMN education.user_activity_log.metadata IS 'Información adicional específica del tipo de actividad'; -COMMENT ON COLUMN education.user_activity_log.xp_earned IS 'XP ganado en esta actividad (si aplica)'; diff --git a/apps/database/ddl/schemas/education/tables/14-course_reviews.sql b/apps/database/ddl/schemas/education/tables/14-course_reviews.sql deleted file mode 100644 index f0dd4d4..0000000 --- a/apps/database/ddl/schemas/education/tables/14-course_reviews.sql +++ /dev/null @@ -1,48 +0,0 @@ --- ===================================================== --- TABLE: education.course_reviews --- ===================================================== --- Proyecto: OrbiQuant IA (Trading Platform) --- Módulo: OQI-002 - Education --- Especificación: Tabla adicional para reviews de cursos --- ===================================================== - -CREATE TABLE education.course_reviews ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - course_id UUID NOT NULL REFERENCES education.courses(id) ON DELETE CASCADE, - enrollment_id UUID NOT NULL REFERENCES education.enrollments(id) ON DELETE CASCADE, - - -- Review - rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5), - title VARCHAR(200), - content TEXT, - - -- Moderación - is_approved BOOLEAN DEFAULT false, - is_featured BOOLEAN DEFAULT false, - approved_by UUID REFERENCES auth.users(id), - approved_at TIMESTAMPTZ, - - -- Votos útiles - helpful_votes INTEGER DEFAULT 0, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - CONSTRAINT unique_user_course_review UNIQUE(user_id, course_id) -); - --- Índices -CREATE INDEX idx_reviews_course ON education.course_reviews(course_id); -CREATE INDEX idx_reviews_user ON education.course_reviews(user_id); -CREATE INDEX idx_reviews_rating ON education.course_reviews(rating); -CREATE INDEX idx_reviews_approved ON education.course_reviews(is_approved) WHERE is_approved = true; -CREATE INDEX idx_reviews_featured ON education.course_reviews(is_featured) WHERE is_featured = true; -CREATE INDEX idx_reviews_helpful ON education.course_reviews(helpful_votes DESC); - --- Comentarios -COMMENT ON TABLE education.course_reviews IS 'Reviews y calificaciones de cursos por usuarios'; -COMMENT ON COLUMN education.course_reviews.is_approved IS 'Review aprobada por moderador'; -COMMENT ON COLUMN education.course_reviews.is_featured IS 'Review destacada para mostrar en página del curso'; -COMMENT ON COLUMN education.course_reviews.helpful_votes IS 'Número de votos útiles de otros usuarios'; diff --git a/apps/database/ddl/schemas/education/uninstall.sh b/apps/database/ddl/schemas/education/uninstall.sh deleted file mode 100755 index 8172255..0000000 --- a/apps/database/ddl/schemas/education/uninstall.sh +++ /dev/null @@ -1,55 +0,0 @@ -#!/bin/bash - -# ===================================================== -# UNINSTALL SCRIPT - Schema Education -# ===================================================== -# Proyecto: OrbiQuant IA (Trading Platform) -# Módulo: OQI-002 - Education -# ===================================================== - -set -e - -# Colors -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' - -# Configuration -DB_HOST="${DB_HOST:-localhost}" -DB_PORT="${DB_PORT:-5432}" -DB_NAME="${DB_NAME:-orbiquant}" -DB_USER="${DB_USER:-postgres}" -SCHEMA_NAME="education" - -echo -e "${YELLOW}=================================================${NC}" -echo -e "${YELLOW} OrbiQuant IA - Education Schema Uninstall${NC}" -echo -e "${YELLOW}=================================================${NC}" -echo "" - -echo -e "${RED}WARNING: This will DROP the entire '$SCHEMA_NAME' schema and ALL its data!${NC}" -echo "" -read -p "Are you sure you want to continue? (type 'yes' to confirm): " CONFIRM - -if [ "$CONFIRM" != "yes" ]; then - echo "Uninstall cancelled." - exit 0 -fi - -echo "" -echo -e "${YELLOW}▶${NC} Dropping schema: $SCHEMA_NAME (CASCADE)" - -if PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c "DROP SCHEMA IF EXISTS $SCHEMA_NAME CASCADE;" > /dev/null 2>&1; then - echo -e "${GREEN} ✓ Schema dropped successfully${NC}" -else - echo -e "${RED} ✗ Failed to drop schema${NC}" - exit 1 -fi - -echo "" -echo -e "${GREEN}=================================================${NC}" -echo -e "${GREEN} Uninstall Complete!${NC}" -echo -e "${GREEN}=================================================${NC}" -echo "" -echo "Schema '$SCHEMA_NAME' has been removed." -echo "" diff --git a/apps/database/ddl/schemas/education/verify.sh b/apps/database/ddl/schemas/education/verify.sh deleted file mode 100755 index 155ff48..0000000 --- a/apps/database/ddl/schemas/education/verify.sh +++ /dev/null @@ -1,145 +0,0 @@ -#!/bin/bash - -# ===================================================== -# VERIFY SCRIPT - Schema Education -# ===================================================== -# Proyecto: OrbiQuant IA (Trading Platform) -# Módulo: OQI-002 - Education -# ===================================================== - -set -e - -# Colors -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' - -# Configuration -DB_HOST="${DB_HOST:-localhost}" -DB_PORT="${DB_PORT:-5432}" -DB_NAME="${DB_NAME:-orbiquant}" -DB_USER="${DB_USER:-postgres}" -SCHEMA_NAME="education" - -echo -e "${BLUE}=================================================${NC}" -echo -e "${BLUE} OrbiQuant IA - Education Schema Verification${NC}" -echo -e "${BLUE}=================================================${NC}" -echo "" - -# Check if psql is available -if ! command -v psql &> /dev/null; then - echo -e "${RED}Error: psql command not found${NC}" - exit 1 -fi - -# Function to run query and return result -run_query() { - local query=$1 - PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c "$query" 2>/dev/null | xargs -} - -echo "Configuration:" -echo " Database: $DB_NAME" -echo " Host: $DB_HOST:$DB_PORT" -echo " Schema: $SCHEMA_NAME" -echo "" - -# Check if schema exists -echo -e "${YELLOW}▶${NC} Checking schema existence..." -SCHEMA_EXISTS=$(run_query "SELECT COUNT(*) FROM information_schema.schemata WHERE schema_name = '$SCHEMA_NAME';") -if [ "$SCHEMA_EXISTS" -eq "1" ]; then - echo -e "${GREEN} ✓ Schema exists${NC}" -else - echo -e "${RED} ✗ Schema not found${NC}" - exit 1 -fi - -# Check ENUMs -echo "" -echo -e "${YELLOW}▶${NC} Checking ENUMs..." -EXPECTED_ENUMS=("difficulty_level" "course_status" "enrollment_status" "lesson_content_type" "question_type" "achievement_type") -ENUM_COUNT=0 - -for enum_name in "${EXPECTED_ENUMS[@]}"; do - EXISTS=$(run_query "SELECT COUNT(*) FROM pg_type WHERE typname = '$enum_name' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = '$SCHEMA_NAME');") - if [ "$EXISTS" -eq "1" ]; then - echo -e "${GREEN} ✓ $enum_name${NC}" - ((ENUM_COUNT++)) - else - echo -e "${RED} ✗ $enum_name${NC}" - fi -done - -# Check tables -echo "" -echo -e "${YELLOW}▶${NC} Checking tables..." -EXPECTED_TABLES=("categories" "courses" "modules" "lessons" "enrollments" "progress" "quizzes" "quiz_questions" "quiz_attempts" "certificates" "user_achievements" "user_gamification_profile" "user_activity_log" "course_reviews") -TABLE_COUNT=0 - -for table_name in "${EXPECTED_TABLES[@]}"; do - EXISTS=$(run_query "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = '$SCHEMA_NAME' AND table_name = '$table_name';") - if [ "$EXISTS" -eq "1" ]; then - ROW_COUNT=$(run_query "SELECT COUNT(*) FROM $SCHEMA_NAME.$table_name;") - echo -e "${GREEN} ✓ $table_name${NC} ($ROW_COUNT rows)" - ((TABLE_COUNT++)) - else - echo -e "${RED} ✗ $table_name${NC}" - fi -done - -# Check functions -echo "" -echo -e "${YELLOW}▶${NC} Checking functions..." -EXPECTED_FUNCTIONS=("update_updated_at_column" "update_enrollment_progress" "auto_complete_enrollment" "generate_certificate_number" "update_course_rating_stats" "update_enrollment_count" "update_user_xp" "update_user_streak") -FUNCTION_COUNT=0 - -for function_name in "${EXPECTED_FUNCTIONS[@]}"; do - EXISTS=$(run_query "SELECT COUNT(*) FROM pg_proc WHERE proname = '$function_name' AND pronamespace = (SELECT oid FROM pg_namespace WHERE nspname = '$SCHEMA_NAME');") - if [ "$EXISTS" -ge "1" ]; then - echo -e "${GREEN} ✓ $function_name${NC}" - ((FUNCTION_COUNT++)) - else - echo -e "${RED} ✗ $function_name${NC}" - fi -done - -# Check views -echo "" -echo -e "${YELLOW}▶${NC} Checking views..." -EXPECTED_VIEWS=("v_courses_with_stats" "v_user_course_progress" "v_leaderboard_weekly" "v_leaderboard_monthly" "v_leaderboard_alltime" "v_user_statistics" "v_popular_courses") -VIEW_COUNT=0 - -for view_name in "${EXPECTED_VIEWS[@]}"; do - EXISTS=$(run_query "SELECT COUNT(*) FROM information_schema.views WHERE table_schema = '$SCHEMA_NAME' AND table_name = '$view_name';") - if [ "$EXISTS" -eq "1" ]; then - echo -e "${GREEN} ✓ $view_name${NC}" - ((VIEW_COUNT++)) - else - echo -e "${RED} ✗ $view_name${NC}" - fi -done - -# Summary -echo "" -echo -e "${BLUE}=================================================${NC}" -echo -e "${BLUE} Verification Summary${NC}" -echo -e "${BLUE}=================================================${NC}" -echo "" -echo "ENUMs: $ENUM_COUNT / ${#EXPECTED_ENUMS[@]}" -echo "Tables: $TABLE_COUNT / ${#EXPECTED_TABLES[@]}" -echo "Functions: $FUNCTION_COUNT / ${#EXPECTED_FUNCTIONS[@]}" -echo "Views: $VIEW_COUNT / ${#EXPECTED_VIEWS[@]}" -echo "" - -TOTAL_EXPECTED=$((${#EXPECTED_ENUMS[@]} + ${#EXPECTED_TABLES[@]} + ${#EXPECTED_FUNCTIONS[@]} + ${#EXPECTED_VIEWS[@]})) -TOTAL_FOUND=$((ENUM_COUNT + TABLE_COUNT + FUNCTION_COUNT + VIEW_COUNT)) - -if [ "$TOTAL_FOUND" -eq "$TOTAL_EXPECTED" ]; then - echo -e "${GREEN}✓ All components verified successfully!${NC}" - exit 0 -else - echo -e "${YELLOW}⚠ Some components are missing ($TOTAL_FOUND / $TOTAL_EXPECTED)${NC}" - exit 1 -fi diff --git a/apps/database/ddl/schemas/financial/00-enums.sql b/apps/database/ddl/schemas/financial/00-enums.sql deleted file mode 100644 index c8619cc..0000000 --- a/apps/database/ddl/schemas/financial/00-enums.sql +++ /dev/null @@ -1,131 +0,0 @@ --- ===================================================== --- ORBIQUANT IA - FINANCIAL SCHEMA ENUMS --- ===================================================== --- Description: Type definitions for financial domain --- Schema: financial --- ===================================================== - --- Tipos de wallet -CREATE TYPE financial.wallet_type AS ENUM ( - 'trading', -- Para operaciones de trading - 'investment', -- Para cuentas PAMM - 'earnings', -- Para ganancias/distribuciones - 'referral' -- Para bonos de referidos -); - --- Estados de wallet -CREATE TYPE financial.wallet_status AS ENUM ( - 'active', - 'frozen', - 'closed' -); - --- Tipos de transacción -CREATE TYPE financial.transaction_type AS ENUM ( - 'deposit', - 'withdrawal', - 'transfer_in', - 'transfer_out', - 'fee', - 'refund', - 'earning', - 'distribution', - 'bonus' -); - --- Estados de transacción -CREATE TYPE financial.transaction_status AS ENUM ( - 'pending', - 'processing', - 'completed', - 'failed', - 'cancelled', - 'reversed' -); - --- Planes de suscripción -CREATE TYPE financial.subscription_plan AS ENUM ( - 'free', - 'basic', - 'pro', - 'premium', - 'enterprise' -); - --- Estados de suscripción -CREATE TYPE financial.subscription_status AS ENUM ( - 'active', - 'past_due', - 'cancelled', - 'incomplete', - 'trialing', - 'unpaid', - 'paused' -); - --- Monedas soportadas -CREATE TYPE financial.currency_code AS ENUM ( - 'USD', - 'MXN', - 'EUR' -); - --- Métodos de pago -CREATE TYPE financial.payment_method AS ENUM ( - 'card', - 'bank_transfer', - 'wire', - 'crypto', - 'paypal', - 'stripe' -); - --- Estados de pago -CREATE TYPE financial.payment_status AS ENUM ( - 'pending', - 'processing', - 'succeeded', - 'failed', - 'cancelled', - 'refunded' -); - --- Tipos de invoice -CREATE TYPE financial.invoice_type AS ENUM ( - 'subscription', - 'one_time', - 'usage' -); - --- Estados de invoice -CREATE TYPE financial.invoice_status AS ENUM ( - 'draft', - 'open', - 'paid', - 'void', - 'uncollectible' -); - --- Acciones de auditoría -CREATE TYPE financial.audit_action AS ENUM ( - 'created', - 'balance_updated', - 'status_changed', - 'limit_changed', - 'frozen', - 'unfrozen', - 'closed' -); - -COMMENT ON TYPE financial.wallet_type IS 'Tipos de wallets en el sistema'; -COMMENT ON TYPE financial.wallet_status IS 'Estados posibles de una wallet'; -COMMENT ON TYPE financial.transaction_type IS 'Tipos de transacciones financieras'; -COMMENT ON TYPE financial.transaction_status IS 'Estados del ciclo de vida de una transacción'; -COMMENT ON TYPE financial.subscription_plan IS 'Planes de suscripción disponibles'; -COMMENT ON TYPE financial.subscription_status IS 'Estados de suscripción según Stripe'; -COMMENT ON TYPE financial.currency_code IS 'Códigos de moneda ISO 4217 soportados'; -COMMENT ON TYPE financial.payment_method IS 'Métodos de pago aceptados'; -COMMENT ON TYPE financial.payment_status IS 'Estados de procesamiento de pagos'; -COMMENT ON TYPE financial.invoice_type IS 'Tipos de factura'; -COMMENT ON TYPE financial.invoice_status IS 'Estados de factura'; -COMMENT ON TYPE financial.audit_action IS 'Acciones auditables en wallets'; diff --git a/apps/database/ddl/schemas/financial/functions/01-update_wallet_balance.sql b/apps/database/ddl/schemas/financial/functions/01-update_wallet_balance.sql deleted file mode 100644 index 1fc9aaf..0000000 --- a/apps/database/ddl/schemas/financial/functions/01-update_wallet_balance.sql +++ /dev/null @@ -1,283 +0,0 @@ --- ===================================================== --- ORBIQUANT IA - UPDATE WALLET BALANCE FUNCTION --- ===================================================== --- Description: Safely update wallet balance with audit trail --- Schema: financial --- ===================================================== - -CREATE OR REPLACE FUNCTION financial.update_wallet_balance( - p_wallet_id UUID, - p_amount DECIMAL(20,8), - p_operation VARCHAR(20), -- 'add', 'subtract', 'set' - p_transaction_id UUID DEFAULT NULL, - p_actor_id UUID DEFAULT NULL, - p_actor_type VARCHAR(50) DEFAULT 'system', - p_reason TEXT DEFAULT NULL, - p_metadata JSONB DEFAULT '{}' -) -RETURNS TABLE ( - success BOOLEAN, - new_balance DECIMAL(20,8), - new_available DECIMAL(20,8), - error_message TEXT -) -LANGUAGE plpgsql -AS $$ -DECLARE - v_wallet RECORD; - v_old_balance DECIMAL(20,8); - v_old_available DECIMAL(20,8); - v_new_balance DECIMAL(20,8); - v_new_available DECIMAL(20,8); -BEGIN - -- Lock wallet row for update - SELECT * INTO v_wallet - FROM financial.wallets - WHERE id = p_wallet_id - FOR UPDATE; - - -- Validar que existe - IF NOT FOUND THEN - RETURN QUERY SELECT false, 0::DECIMAL, 0::DECIMAL, 'Wallet not found'; - RETURN; - END IF; - - -- Validar que está activa - IF v_wallet.status != 'active' THEN - RETURN QUERY SELECT false, v_wallet.balance, v_wallet.available_balance, - 'Wallet is not active (status: ' || v_wallet.status::TEXT || ')'; - RETURN; - END IF; - - -- Guardar valores antiguos - v_old_balance := v_wallet.balance; - v_old_available := v_wallet.available_balance; - - -- Calcular nuevo balance según operación - CASE p_operation - WHEN 'add' THEN - v_new_balance := v_old_balance + p_amount; - v_new_available := v_old_available + p_amount; - WHEN 'subtract' THEN - v_new_balance := v_old_balance - p_amount; - v_new_available := v_old_available - p_amount; - WHEN 'set' THEN - v_new_balance := p_amount; - v_new_available := p_amount - v_wallet.pending_balance; - ELSE - RETURN QUERY SELECT false, v_old_balance, v_old_available, - 'Invalid operation: ' || p_operation; - RETURN; - END CASE; - - -- Validar que no quede negativo - IF v_new_balance < 0 THEN - RETURN QUERY SELECT false, v_old_balance, v_old_available, - 'Insufficient balance (current: ' || v_old_balance::TEXT || ', required: ' || p_amount::TEXT || ')'; - RETURN; - END IF; - - IF v_new_available < 0 THEN - RETURN QUERY SELECT false, v_old_balance, v_old_available, - 'Insufficient available balance (current: ' || v_old_available::TEXT || ')'; - RETURN; - END IF; - - -- Validar min_balance si existe - IF v_wallet.min_balance IS NOT NULL AND v_new_available < v_wallet.min_balance THEN - RETURN QUERY SELECT false, v_old_balance, v_old_available, - 'Would violate minimum balance requirement (min: ' || v_wallet.min_balance::TEXT || ')'; - RETURN; - END IF; - - -- Actualizar wallet - UPDATE financial.wallets - SET - balance = v_new_balance, - available_balance = v_new_available, - last_transaction_at = NOW(), - updated_at = NOW() - WHERE id = p_wallet_id; - - -- Registrar en audit log - INSERT INTO financial.wallet_audit_log ( - wallet_id, - action, - actor_id, - actor_type, - old_values, - new_values, - balance_before, - balance_after, - transaction_id, - reason, - metadata - ) VALUES ( - p_wallet_id, - 'balance_updated', - p_actor_id, - p_actor_type, - jsonb_build_object( - 'balance', v_old_balance, - 'available_balance', v_old_available - ), - jsonb_build_object( - 'balance', v_new_balance, - 'available_balance', v_new_available - ), - v_old_balance, - v_new_balance, - p_transaction_id, - p_reason, - p_metadata - ); - - -- Retornar éxito - RETURN QUERY SELECT true, v_new_balance, v_new_available, NULL::TEXT; -END; -$$; - -COMMENT ON FUNCTION financial.update_wallet_balance IS 'Safely update wallet balance with validation and audit trail'; - --- Función helper para reservar fondos (pending balance) -CREATE OR REPLACE FUNCTION financial.reserve_wallet_funds( - p_wallet_id UUID, - p_amount DECIMAL(20,8), - p_reason TEXT DEFAULT NULL -) -RETURNS TABLE ( - success BOOLEAN, - new_available DECIMAL(20,8), - new_pending DECIMAL(20,8), - error_message TEXT -) -LANGUAGE plpgsql -AS $$ -DECLARE - v_wallet RECORD; -BEGIN - -- Lock wallet - SELECT * INTO v_wallet - FROM financial.wallets - WHERE id = p_wallet_id - FOR UPDATE; - - IF NOT FOUND THEN - RETURN QUERY SELECT false, 0::DECIMAL, 0::DECIMAL, 'Wallet not found'; - RETURN; - END IF; - - IF v_wallet.status != 'active' THEN - RETURN QUERY SELECT false, v_wallet.available_balance, v_wallet.pending_balance, - 'Wallet is not active'; - RETURN; - END IF; - - IF v_wallet.available_balance < p_amount THEN - RETURN QUERY SELECT false, v_wallet.available_balance, v_wallet.pending_balance, - 'Insufficient available balance'; - RETURN; - END IF; - - -- Mover de available a pending - UPDATE financial.wallets - SET - available_balance = available_balance - p_amount, - pending_balance = pending_balance + p_amount, - updated_at = NOW() - WHERE id = p_wallet_id; - - -- Audit log - INSERT INTO financial.wallet_audit_log ( - wallet_id, action, actor_type, reason, - old_values, new_values - ) VALUES ( - p_wallet_id, 'balance_updated', 'system', p_reason, - jsonb_build_object('available', v_wallet.available_balance, 'pending', v_wallet.pending_balance), - jsonb_build_object('available', v_wallet.available_balance - p_amount, 'pending', v_wallet.pending_balance + p_amount) - ); - - RETURN QUERY SELECT - true, - v_wallet.available_balance - p_amount, - v_wallet.pending_balance + p_amount, - NULL::TEXT; -END; -$$; - -COMMENT ON FUNCTION financial.reserve_wallet_funds IS 'Reserve funds by moving from available to pending balance'; - --- Función helper para liberar fondos reservados -CREATE OR REPLACE FUNCTION financial.release_wallet_funds( - p_wallet_id UUID, - p_amount DECIMAL(20,8), - p_to_available BOOLEAN DEFAULT true, - p_reason TEXT DEFAULT NULL -) -RETURNS TABLE ( - success BOOLEAN, - new_available DECIMAL(20,8), - new_pending DECIMAL(20,8), - error_message TEXT -) -LANGUAGE plpgsql -AS $$ -DECLARE - v_wallet RECORD; - v_new_balance DECIMAL(20,8); -BEGIN - -- Lock wallet - SELECT * INTO v_wallet - FROM financial.wallets - WHERE id = p_wallet_id - FOR UPDATE; - - IF NOT FOUND THEN - RETURN QUERY SELECT false, 0::DECIMAL, 0::DECIMAL, 'Wallet not found'; - RETURN; - END IF; - - IF v_wallet.pending_balance < p_amount THEN - RETURN QUERY SELECT false, v_wallet.available_balance, v_wallet.pending_balance, - 'Insufficient pending balance'; - RETURN; - END IF; - - -- Liberar fondos - IF p_to_available THEN - -- Devolver a available - v_new_balance := v_wallet.balance; - UPDATE financial.wallets - SET - available_balance = available_balance + p_amount, - pending_balance = pending_balance - p_amount, - updated_at = NOW() - WHERE id = p_wallet_id; - ELSE - -- Remover completamente (ej: después de withdrawal exitoso) - v_new_balance := v_wallet.balance - p_amount; - UPDATE financial.wallets - SET - balance = balance - p_amount, - pending_balance = pending_balance - p_amount, - updated_at = NOW() - WHERE id = p_wallet_id; - END IF; - - -- Audit log - INSERT INTO financial.wallet_audit_log ( - wallet_id, action, actor_type, reason, metadata - ) VALUES ( - p_wallet_id, 'balance_updated', 'system', p_reason, - jsonb_build_object('released_amount', p_amount, 'to_available', p_to_available) - ); - - SELECT available_balance, pending_balance INTO v_wallet - FROM financial.wallets - WHERE id = p_wallet_id; - - RETURN QUERY SELECT true, v_wallet.available_balance, v_wallet.pending_balance, NULL::TEXT; -END; -$$; - -COMMENT ON FUNCTION financial.release_wallet_funds IS 'Release reserved funds back to available or remove from balance'; diff --git a/apps/database/ddl/schemas/financial/functions/02-process_transaction.sql b/apps/database/ddl/schemas/financial/functions/02-process_transaction.sql deleted file mode 100644 index 03530d6..0000000 --- a/apps/database/ddl/schemas/financial/functions/02-process_transaction.sql +++ /dev/null @@ -1,326 +0,0 @@ --- ===================================================== --- ORBIQUANT IA - PROCESS TRANSACTION FUNCTION --- ===================================================== --- Description: Create and process wallet transactions atomically --- Schema: financial --- ===================================================== - -CREATE OR REPLACE FUNCTION financial.process_transaction( - p_wallet_id UUID, - p_transaction_type financial.transaction_type, - p_amount DECIMAL(20,8), - p_currency financial.currency_code, - p_fee DECIMAL(15,8) DEFAULT 0, - p_description TEXT DEFAULT NULL, - p_reference_id VARCHAR(100) DEFAULT NULL, - p_destination_wallet_id UUID DEFAULT NULL, - p_idempotency_key VARCHAR(255) DEFAULT NULL, - p_metadata JSONB DEFAULT '{}', - p_auto_complete BOOLEAN DEFAULT false -) -RETURNS TABLE ( - success BOOLEAN, - transaction_id UUID, - new_balance DECIMAL(20,8), - error_message TEXT -) -LANGUAGE plpgsql -AS $$ -DECLARE - v_wallet RECORD; - v_tx_id UUID; - v_balance_before DECIMAL(20,8); - v_balance_after DECIMAL(20,8); - v_update_result RECORD; - v_dest_tx_id UUID; -BEGIN - -- Validar idempotency - IF p_idempotency_key IS NOT NULL THEN - SELECT id, wallet_transactions.status INTO v_tx_id, v_wallet - FROM financial.wallet_transactions - WHERE idempotency_key = p_idempotency_key; - - IF FOUND THEN - -- Transacción ya existe - SELECT balance INTO v_balance_after - FROM financial.wallets - WHERE id = p_wallet_id; - - RETURN QUERY SELECT - true, - v_tx_id, - v_balance_after, - 'Transaction already exists (idempotent)'::TEXT; - RETURN; - END IF; - END IF; - - -- Lock wallet - SELECT * INTO v_wallet - FROM financial.wallets - WHERE id = p_wallet_id - FOR UPDATE; - - IF NOT FOUND THEN - RETURN QUERY SELECT false, NULL::UUID, 0::DECIMAL, 'Wallet not found'; - RETURN; - END IF; - - IF v_wallet.status != 'active' THEN - RETURN QUERY SELECT false, NULL::UUID, v_wallet.balance, - 'Wallet is not active (status: ' || v_wallet.status::TEXT || ')'; - RETURN; - END IF; - - -- Validar currency match - IF v_wallet.currency != p_currency THEN - RETURN QUERY SELECT false, NULL::UUID, v_wallet.balance, - 'Currency mismatch (wallet: ' || v_wallet.currency::TEXT || ', transaction: ' || p_currency::TEXT || ')'; - RETURN; - END IF; - - -- Validar destination para transfers - IF p_transaction_type IN ('transfer_out', 'transfer_in') AND p_destination_wallet_id IS NULL THEN - RETURN QUERY SELECT false, NULL::UUID, v_wallet.balance, - 'Transfer requires destination_wallet_id'; - RETURN; - END IF; - - -- No permitir self-transfers - IF p_destination_wallet_id = p_wallet_id THEN - RETURN QUERY SELECT false, NULL::UUID, v_wallet.balance, - 'Cannot transfer to same wallet'; - RETURN; - END IF; - - v_balance_before := v_wallet.balance; - - -- Crear transacción - INSERT INTO financial.wallet_transactions ( - wallet_id, - transaction_type, - status, - amount, - fee, - currency, - balance_before, - destination_wallet_id, - reference_id, - description, - metadata, - idempotency_key, - processed_at - ) VALUES ( - p_wallet_id, - p_transaction_type, - CASE WHEN p_auto_complete THEN 'completed'::financial.transaction_status - ELSE 'pending'::financial.transaction_status END, - p_amount, - p_fee, - p_currency, - v_balance_before, - p_destination_wallet_id, - p_reference_id, - p_description, - p_metadata, - p_idempotency_key, - CASE WHEN p_auto_complete THEN NOW() ELSE NULL END - ) - RETURNING id INTO v_tx_id; - - -- Si es auto_complete, procesar inmediatamente - IF p_auto_complete THEN - -- Determinar operación de balance - CASE p_transaction_type - WHEN 'deposit', 'transfer_in', 'earning', 'distribution', 'bonus', 'refund' THEN - -- Aumentar balance - SELECT * INTO v_update_result - FROM financial.update_wallet_balance( - p_wallet_id, - p_amount - p_fee, - 'add', - v_tx_id, - NULL, - 'system', - 'Transaction: ' || p_transaction_type::TEXT - ); - - WHEN 'withdrawal', 'transfer_out', 'fee' THEN - -- Disminuir balance - SELECT * INTO v_update_result - FROM financial.update_wallet_balance( - p_wallet_id, - p_amount + p_fee, - 'subtract', - v_tx_id, - NULL, - 'system', - 'Transaction: ' || p_transaction_type::TEXT - ); - - ELSE - RETURN QUERY SELECT false, v_tx_id, v_balance_before, - 'Unknown transaction type: ' || p_transaction_type::TEXT; - RETURN; - END CASE; - - -- Verificar éxito de actualización - IF NOT v_update_result.success THEN - -- Marcar transacción como fallida - UPDATE financial.wallet_transactions - SET - status = 'failed', - failed_reason = v_update_result.error_message, - failed_at = NOW() - WHERE id = v_tx_id; - - RETURN QUERY SELECT false, v_tx_id, v_balance_before, v_update_result.error_message; - RETURN; - END IF; - - v_balance_after := v_update_result.new_balance; - - -- Actualizar balance_after en transacción - UPDATE financial.wallet_transactions - SET - balance_after = v_balance_after, - completed_at = NOW() - WHERE id = v_tx_id; - - -- Si es transfer_out, crear transfer_in en destino - IF p_transaction_type = 'transfer_out' AND p_destination_wallet_id IS NOT NULL THEN - SELECT * INTO v_update_result - FROM financial.process_transaction( - p_destination_wallet_id, - 'transfer_in', - p_amount - p_fee, -- El fee lo paga el origen - p_currency, - 0, -- Sin fee adicional en destino - 'Transfer from wallet ' || p_wallet_id::TEXT, - p_reference_id, - p_wallet_id, -- Origen como destino inverso - p_idempotency_key || '_dest', -- Idempotency para destino - p_metadata, - true -- Auto-complete - ); - - IF v_update_result.success THEN - v_dest_tx_id := v_update_result.transaction_id; - - -- Vincular transacciones - UPDATE financial.wallet_transactions - SET related_transaction_id = v_dest_tx_id - WHERE id = v_tx_id; - - UPDATE financial.wallet_transactions - SET related_transaction_id = v_tx_id - WHERE id = v_dest_tx_id; - END IF; - END IF; - - -- Actualizar totals en wallet - IF p_transaction_type IN ('deposit', 'transfer_in') THEN - UPDATE financial.wallets - SET total_deposits = total_deposits + p_amount - WHERE id = p_wallet_id; - ELSIF p_transaction_type IN ('withdrawal', 'transfer_out') THEN - UPDATE financial.wallets - SET total_withdrawals = total_withdrawals + p_amount - WHERE id = p_wallet_id; - END IF; - - ELSE - -- Transaction pending, no balance update yet - v_balance_after := v_balance_before; - END IF; - - RETURN QUERY SELECT true, v_tx_id, v_balance_after, NULL::TEXT; -END; -$$; - -COMMENT ON FUNCTION financial.process_transaction IS 'Create and optionally complete a wallet transaction atomically'; - --- Función para completar transacción pendiente -CREATE OR REPLACE FUNCTION financial.complete_transaction( - p_transaction_id UUID -) -RETURNS TABLE ( - success BOOLEAN, - new_balance DECIMAL(20,8), - error_message TEXT -) -LANGUAGE plpgsql -AS $$ -DECLARE - v_tx RECORD; - v_update_result RECORD; -BEGIN - -- Lock transaction - SELECT * INTO v_tx - FROM financial.wallet_transactions - WHERE id = p_transaction_id - FOR UPDATE; - - IF NOT FOUND THEN - RETURN QUERY SELECT false, 0::DECIMAL, 'Transaction not found'; - RETURN; - END IF; - - IF v_tx.status != 'pending' THEN - RETURN QUERY SELECT false, 0::DECIMAL, - 'Transaction is not pending (status: ' || v_tx.status::TEXT || ')'; - RETURN; - END IF; - - -- Procesar según tipo - CASE v_tx.transaction_type - WHEN 'deposit', 'transfer_in', 'earning', 'distribution', 'bonus', 'refund' THEN - SELECT * INTO v_update_result - FROM financial.update_wallet_balance( - v_tx.wallet_id, - v_tx.amount - v_tx.fee, - 'add', - p_transaction_id - ); - - WHEN 'withdrawal', 'transfer_out', 'fee' THEN - SELECT * INTO v_update_result - FROM financial.update_wallet_balance( - v_tx.wallet_id, - v_tx.amount + v_tx.fee, - 'subtract', - p_transaction_id - ); - - ELSE - RETURN QUERY SELECT false, 0::DECIMAL, 'Unknown transaction type'; - RETURN; - END CASE; - - IF NOT v_update_result.success THEN - -- Marcar como fallida - UPDATE financial.wallet_transactions - SET - status = 'failed', - failed_reason = v_update_result.error_message, - failed_at = NOW() - WHERE id = p_transaction_id; - - RETURN QUERY SELECT false, 0::DECIMAL, v_update_result.error_message; - RETURN; - END IF; - - -- Marcar como completada - UPDATE financial.wallet_transactions - SET - status = 'completed', - balance_after = v_update_result.new_balance, - completed_at = NOW(), - processed_at = COALESCE(processed_at, NOW()) - WHERE id = p_transaction_id; - - RETURN QUERY SELECT true, v_update_result.new_balance, NULL::TEXT; -END; -$$; - -COMMENT ON FUNCTION financial.complete_transaction IS 'Complete a pending wallet transaction'; diff --git a/apps/database/ddl/schemas/financial/functions/03-triggers.sql b/apps/database/ddl/schemas/financial/functions/03-triggers.sql deleted file mode 100644 index c245ff3..0000000 --- a/apps/database/ddl/schemas/financial/functions/03-triggers.sql +++ /dev/null @@ -1,278 +0,0 @@ --- ===================================================== --- ORBIQUANT IA - FINANCIAL SCHEMA TRIGGERS --- ===================================================== --- Description: Automated triggers for data integrity and audit --- Schema: financial --- ===================================================== - --- ===================================================== --- TRIGGER: Update timestamps --- ===================================================== - -CREATE OR REPLACE FUNCTION financial.update_timestamp() -RETURNS TRIGGER -LANGUAGE plpgsql -AS $$ -BEGIN - NEW.updated_at = NOW(); - RETURN NEW; -END; -$$; - --- Apply to all tables with updated_at -CREATE TRIGGER trigger_wallets_updated_at - BEFORE UPDATE ON financial.wallets - FOR EACH ROW - EXECUTE FUNCTION financial.update_timestamp(); - -CREATE TRIGGER trigger_transactions_updated_at - BEFORE UPDATE ON financial.wallet_transactions - FOR EACH ROW - EXECUTE FUNCTION financial.update_timestamp(); - -CREATE TRIGGER trigger_subscriptions_updated_at - BEFORE UPDATE ON financial.subscriptions - FOR EACH ROW - EXECUTE FUNCTION financial.update_timestamp(); - -CREATE TRIGGER trigger_payments_updated_at - BEFORE UPDATE ON financial.payments - FOR EACH ROW - EXECUTE FUNCTION financial.update_timestamp(); - -CREATE TRIGGER trigger_invoices_updated_at - BEFORE UPDATE ON financial.invoices - FOR EACH ROW - EXECUTE FUNCTION financial.update_timestamp(); - -CREATE TRIGGER trigger_exchange_rates_updated_at - BEFORE UPDATE ON financial.currency_exchange_rates - FOR EACH ROW - EXECUTE FUNCTION financial.update_timestamp(); - -CREATE TRIGGER trigger_wallet_limits_updated_at - BEFORE UPDATE ON financial.wallet_limits - FOR EACH ROW - EXECUTE FUNCTION financial.update_timestamp(); - --- ===================================================== --- TRIGGER: Auto-generate invoice number --- ===================================================== - -CREATE OR REPLACE FUNCTION financial.generate_invoice_number() -RETURNS TRIGGER -LANGUAGE plpgsql -AS $$ -BEGIN - IF NEW.invoice_number IS NULL THEN - NEW.invoice_number := 'INV-' || TO_CHAR(NOW(), 'YYYYMM') || '-' || - LPAD(nextval('financial.invoice_number_seq')::TEXT, 6, '0'); - END IF; - RETURN NEW; -END; -$$; - -CREATE TRIGGER trigger_invoice_number - BEFORE INSERT ON financial.invoices - FOR EACH ROW - EXECUTE FUNCTION financial.generate_invoice_number(); - --- ===================================================== --- TRIGGER: Validate wallet balance consistency --- ===================================================== - -CREATE OR REPLACE FUNCTION financial.validate_wallet_balance() -RETURNS TRIGGER -LANGUAGE plpgsql -AS $$ -BEGIN - -- Validar que balance = available + pending - IF NEW.balance != (NEW.available_balance + NEW.pending_balance) THEN - RAISE EXCEPTION 'Balance consistency error: balance (%) != available (%) + pending (%)', - NEW.balance, NEW.available_balance, NEW.pending_balance; - END IF; - - -- Validar que no haya negativos - IF NEW.balance < 0 OR NEW.available_balance < 0 OR NEW.pending_balance < 0 THEN - RAISE EXCEPTION 'Negative balance detected: balance=%, available=%, pending=%', - NEW.balance, NEW.available_balance, NEW.pending_balance; - END IF; - - RETURN NEW; -END; -$$; - -CREATE TRIGGER trigger_wallet_balance_validation - BEFORE INSERT OR UPDATE ON financial.wallets - FOR EACH ROW - EXECUTE FUNCTION financial.validate_wallet_balance(); - --- ===================================================== --- TRIGGER: Audit wallet status changes --- ===================================================== - -CREATE OR REPLACE FUNCTION financial.audit_wallet_status_change() -RETURNS TRIGGER -LANGUAGE plpgsql -AS $$ -BEGIN - -- Solo auditar si cambió el status - IF TG_OP = 'UPDATE' AND OLD.status IS DISTINCT FROM NEW.status THEN - INSERT INTO financial.wallet_audit_log ( - wallet_id, - action, - actor_type, - old_values, - new_values, - reason - ) VALUES ( - NEW.id, - 'status_changed', - 'system', - jsonb_build_object('status', OLD.status), - jsonb_build_object('status', NEW.status), - 'Status changed from ' || OLD.status::TEXT || ' to ' || NEW.status::TEXT - ); - - -- Si se cerró, registrar timestamp - IF NEW.status = 'closed' AND NEW.closed_at IS NULL THEN - NEW.closed_at := NOW(); - END IF; - END IF; - - RETURN NEW; -END; -$$; - -CREATE TRIGGER trigger_wallet_status_audit - BEFORE UPDATE ON financial.wallets - FOR EACH ROW - EXECUTE FUNCTION financial.audit_wallet_status_change(); - --- ===================================================== --- TRIGGER: Prevent modification of completed transactions --- ===================================================== - -CREATE OR REPLACE FUNCTION financial.protect_completed_transactions() -RETURNS TRIGGER -LANGUAGE plpgsql -AS $$ -BEGIN - IF OLD.status = 'completed' AND NEW.status != 'completed' THEN - RAISE EXCEPTION 'Cannot modify completed transaction %', OLD.id; - END IF; - - IF OLD.status = 'completed' AND ( - OLD.amount != NEW.amount OR - OLD.wallet_id != NEW.wallet_id OR - OLD.transaction_type != NEW.transaction_type - ) THEN - RAISE EXCEPTION 'Cannot modify core fields of completed transaction %', OLD.id; - END IF; - - RETURN NEW; -END; -$$; - -CREATE TRIGGER trigger_protect_completed_tx - BEFORE UPDATE ON financial.wallet_transactions - FOR EACH ROW - EXECUTE FUNCTION financial.protect_completed_transactions(); - --- ===================================================== --- TRIGGER: Set payment succeeded_at timestamp --- ===================================================== - -CREATE OR REPLACE FUNCTION financial.set_payment_timestamps() -RETURNS TRIGGER -LANGUAGE plpgsql -AS $$ -BEGIN - -- Set succeeded_at when status changes to succeeded - IF NEW.status = 'succeeded' AND OLD.status != 'succeeded' THEN - NEW.succeeded_at := NOW(); - END IF; - - -- Set failed_at when status changes to failed - IF NEW.status = 'failed' AND OLD.status != 'failed' THEN - NEW.failed_at := NOW(); - END IF; - - RETURN NEW; -END; -$$; - -CREATE TRIGGER trigger_payment_timestamps - BEFORE UPDATE ON financial.payments - FOR EACH ROW - EXECUTE FUNCTION financial.set_payment_timestamps(); - --- ===================================================== --- TRIGGER: Update subscription ended_at --- ===================================================== - -CREATE OR REPLACE FUNCTION financial.set_subscription_ended_at() -RETURNS TRIGGER -LANGUAGE plpgsql -AS $$ -BEGIN - -- Set ended_at when status changes to cancelled and cancel_at_period_end is false - IF NEW.status = 'cancelled' AND - OLD.status != 'cancelled' AND - NOT NEW.cancel_at_period_end AND - NEW.ended_at IS NULL THEN - NEW.ended_at := NOW(); - END IF; - - RETURN NEW; -END; -$$; - -CREATE TRIGGER trigger_subscription_ended_at - BEFORE UPDATE ON financial.subscriptions - FOR EACH ROW - EXECUTE FUNCTION financial.set_subscription_ended_at(); - --- ===================================================== --- TRIGGER: Validate transaction currency matches wallet --- ===================================================== - -CREATE OR REPLACE FUNCTION financial.validate_transaction_currency() -RETURNS TRIGGER -LANGUAGE plpgsql -AS $$ -DECLARE - v_wallet_currency financial.currency_code; -BEGIN - -- Get wallet currency - SELECT currency INTO v_wallet_currency - FROM financial.wallets - WHERE id = NEW.wallet_id; - - IF NOT FOUND THEN - RAISE EXCEPTION 'Wallet % not found', NEW.wallet_id; - END IF; - - -- Validate currency match - IF NEW.currency != v_wallet_currency THEN - RAISE EXCEPTION 'Transaction currency (%) does not match wallet currency (%)', - NEW.currency, v_wallet_currency; - END IF; - - RETURN NEW; -END; -$$; - -CREATE TRIGGER trigger_transaction_currency_validation - BEFORE INSERT ON financial.wallet_transactions - FOR EACH ROW - EXECUTE FUNCTION financial.validate_transaction_currency(); - -COMMENT ON FUNCTION financial.update_timestamp IS 'Auto-update updated_at timestamp'; -COMMENT ON FUNCTION financial.generate_invoice_number IS 'Auto-generate invoice number with format INV-YYYYMM-XXXXXX'; -COMMENT ON FUNCTION financial.validate_wallet_balance IS 'Ensure balance = available + pending'; -COMMENT ON FUNCTION financial.audit_wallet_status_change IS 'Log wallet status changes to audit log'; -COMMENT ON FUNCTION financial.protect_completed_transactions IS 'Prevent modification of completed transactions'; -COMMENT ON FUNCTION financial.set_payment_timestamps IS 'Auto-set succeeded_at and failed_at timestamps'; -COMMENT ON FUNCTION financial.set_subscription_ended_at IS 'Auto-set ended_at when subscription is cancelled'; -COMMENT ON FUNCTION financial.validate_transaction_currency IS 'Ensure transaction currency matches wallet currency'; diff --git a/apps/database/ddl/schemas/financial/functions/04-views.sql b/apps/database/ddl/schemas/financial/functions/04-views.sql deleted file mode 100644 index a179df8..0000000 --- a/apps/database/ddl/schemas/financial/functions/04-views.sql +++ /dev/null @@ -1,258 +0,0 @@ --- ===================================================== --- ORBIQUANT IA - FINANCIAL SCHEMA VIEWS --- ===================================================== --- Description: Useful views for common financial queries --- Schema: financial --- ===================================================== - --- ===================================================== --- VIEW: Active user wallets summary --- ===================================================== - -CREATE OR REPLACE VIEW financial.v_user_wallets_summary AS -SELECT - w.user_id, - w.wallet_type, - w.currency, - w.balance, - w.available_balance, - w.pending_balance, - w.status, - w.last_transaction_at, - w.total_deposits, - w.total_withdrawals, - w.created_at, - -- Transaction counts - (SELECT COUNT(*) - FROM financial.wallet_transactions wt - WHERE wt.wallet_id = w.id AND wt.status = 'completed') as total_transactions, - (SELECT COUNT(*) - FROM financial.wallet_transactions wt - WHERE wt.wallet_id = w.id AND wt.status = 'pending') as pending_transactions, - -- Latest transaction - (SELECT wt.created_at - FROM financial.wallet_transactions wt - WHERE wt.wallet_id = w.id - ORDER BY wt.created_at DESC - LIMIT 1) as last_tx_date -FROM financial.wallets w -WHERE w.status = 'active'; - -COMMENT ON VIEW financial.v_user_wallets_summary IS 'Active wallets with transaction statistics'; - --- ===================================================== --- VIEW: User total balance across all wallets (USD) --- ===================================================== - -CREATE OR REPLACE VIEW financial.v_user_total_balance AS -SELECT - user_id, - SUM(CASE WHEN currency = 'USD' THEN balance ELSE 0 END) as total_usd, - SUM(CASE WHEN currency = 'MXN' THEN balance ELSE 0 END) as total_mxn, - SUM(CASE WHEN currency = 'EUR' THEN balance ELSE 0 END) as total_eur, - -- Totals by wallet type - SUM(CASE WHEN wallet_type = 'trading' AND currency = 'USD' THEN balance ELSE 0 END) as trading_usd, - SUM(CASE WHEN wallet_type = 'investment' AND currency = 'USD' THEN balance ELSE 0 END) as investment_usd, - SUM(CASE WHEN wallet_type = 'earnings' AND currency = 'USD' THEN balance ELSE 0 END) as earnings_usd, - SUM(CASE WHEN wallet_type = 'referral' AND currency = 'USD' THEN balance ELSE 0 END) as referral_usd, - COUNT(*) as wallet_count, - MAX(last_transaction_at) as last_activity -FROM financial.wallets -WHERE status = 'active' -GROUP BY user_id; - -COMMENT ON VIEW financial.v_user_total_balance IS 'Aggregated balance per user across all wallets'; - --- ===================================================== --- VIEW: Recent transactions (last 30 days) --- ===================================================== - -CREATE OR REPLACE VIEW financial.v_recent_transactions AS -SELECT - wt.id, - wt.wallet_id, - w.user_id, - w.wallet_type, - wt.transaction_type, - wt.status, - wt.amount, - wt.fee, - wt.net_amount, - wt.currency, - wt.description, - wt.reference_id, - wt.balance_before, - wt.balance_after, - wt.created_at, - wt.completed_at, - -- Days since transaction - EXTRACT(DAY FROM NOW() - wt.created_at) as days_ago -FROM financial.wallet_transactions wt -JOIN financial.wallets w ON w.id = wt.wallet_id -WHERE wt.created_at >= NOW() - INTERVAL '30 days' -ORDER BY wt.created_at DESC; - -COMMENT ON VIEW financial.v_recent_transactions IS 'Wallet transactions from last 30 days'; - --- ===================================================== --- VIEW: Active subscriptions with user details --- ===================================================== - -CREATE OR REPLACE VIEW financial.v_active_subscriptions AS -SELECT - s.id, - s.user_id, - s.plan, - s.status, - s.price, - s.currency, - s.billing_interval, - s.current_period_start, - s.current_period_end, - s.next_payment_at, - s.cancel_at_period_end, - s.stripe_subscription_id, - -- Days until renewal - EXTRACT(DAY FROM s.current_period_end - NOW()) as days_until_renewal, - -- Is in trial - (s.status = 'trialing') as is_trial, - -- Trial days remaining - CASE - WHEN s.trial_end IS NOT NULL THEN EXTRACT(DAY FROM s.trial_end - NOW()) - ELSE NULL - END as trial_days_remaining -FROM financial.subscriptions s -WHERE s.status IN ('active', 'trialing', 'past_due') -ORDER BY s.current_period_end ASC; - -COMMENT ON VIEW financial.v_active_subscriptions IS 'Active subscriptions with renewal information'; - --- ===================================================== --- VIEW: Pending payments --- ===================================================== - -CREATE OR REPLACE VIEW financial.v_pending_payments AS -SELECT - p.id, - p.user_id, - p.subscription_id, - p.amount, - p.currency, - p.payment_method, - p.status, - p.description, - p.stripe_payment_intent_id, - p.created_at, - -- Days pending - EXTRACT(DAY FROM NOW() - p.created_at) as days_pending -FROM financial.payments p -WHERE p.status IN ('pending', 'processing') -ORDER BY p.created_at ASC; - -COMMENT ON VIEW financial.v_pending_payments IS 'Payments awaiting completion'; - --- ===================================================== --- VIEW: Unpaid invoices --- ===================================================== - -CREATE OR REPLACE VIEW financial.v_unpaid_invoices AS -SELECT - i.id, - i.user_id, - i.invoice_number, - i.total, - i.amount_due, - i.currency, - i.due_date, - i.status, - i.invoice_date, - i.hosted_invoice_url, - -- Days overdue - CASE - WHEN i.due_date IS NOT NULL AND i.due_date < NOW() - THEN EXTRACT(DAY FROM NOW() - i.due_date) - ELSE 0 - END as days_overdue, - -- Is overdue - (i.due_date IS NOT NULL AND i.due_date < NOW()) as is_overdue -FROM financial.invoices i -WHERE i.status = 'open' AND i.paid = false -ORDER BY i.due_date ASC NULLS LAST; - -COMMENT ON VIEW financial.v_unpaid_invoices IS 'Open invoices with overdue status'; - --- ===================================================== --- VIEW: Daily transaction volume --- ===================================================== - -CREATE OR REPLACE VIEW financial.v_daily_transaction_volume AS -SELECT - DATE(wt.created_at) as transaction_date, - wt.transaction_type, - wt.currency, - COUNT(*) as transaction_count, - SUM(wt.amount) as total_amount, - SUM(wt.fee) as total_fees, - SUM(wt.net_amount) as total_net_amount, - AVG(wt.amount) as avg_amount -FROM financial.wallet_transactions wt -WHERE wt.status = 'completed' - AND wt.created_at >= NOW() - INTERVAL '90 days' -GROUP BY DATE(wt.created_at), wt.transaction_type, wt.currency -ORDER BY transaction_date DESC, transaction_type; - -COMMENT ON VIEW financial.v_daily_transaction_volume IS 'Daily aggregated transaction statistics'; - --- ===================================================== --- VIEW: Wallet activity summary (last 7 days) --- ===================================================== - -CREATE OR REPLACE VIEW financial.v_wallet_activity_7d AS -SELECT - w.id as wallet_id, - w.user_id, - w.wallet_type, - w.currency, - w.balance, - -- Transaction counts by type - COUNT(CASE WHEN wt.transaction_type = 'deposit' THEN 1 END) as deposits_7d, - COUNT(CASE WHEN wt.transaction_type = 'withdrawal' THEN 1 END) as withdrawals_7d, - COUNT(CASE WHEN wt.transaction_type IN ('transfer_in', 'transfer_out') THEN 1 END) as transfers_7d, - -- Amounts - SUM(CASE WHEN wt.transaction_type = 'deposit' THEN wt.amount ELSE 0 END) as deposit_amount_7d, - SUM(CASE WHEN wt.transaction_type = 'withdrawal' THEN wt.amount ELSE 0 END) as withdrawal_amount_7d, - -- Total activity - COUNT(wt.id) as total_transactions_7d -FROM financial.wallets w -LEFT JOIN financial.wallet_transactions wt ON wt.wallet_id = w.id - AND wt.created_at >= NOW() - INTERVAL '7 days' - AND wt.status = 'completed' -WHERE w.status = 'active' -GROUP BY w.id, w.user_id, w.wallet_type, w.currency, w.balance; - -COMMENT ON VIEW financial.v_wallet_activity_7d IS 'Wallet activity summary for last 7 days'; - --- ===================================================== --- VIEW: Subscription revenue metrics --- ===================================================== - -CREATE OR REPLACE VIEW financial.v_subscription_revenue AS -SELECT - s.plan, - s.billing_interval, - s.currency, - COUNT(*) as active_count, - SUM(s.price) as total_monthly_value, - AVG(s.price) as avg_price, - -- MRR calculation (Monthly Recurring Revenue) - SUM(CASE - WHEN s.billing_interval = 'month' THEN s.price - WHEN s.billing_interval = 'year' THEN s.price / 12 - ELSE 0 - END) as monthly_recurring_revenue -FROM financial.subscriptions s -WHERE s.status IN ('active', 'trialing') -GROUP BY s.plan, s.billing_interval, s.currency -ORDER BY s.plan, s.billing_interval; - -COMMENT ON VIEW financial.v_subscription_revenue IS 'Subscription metrics and MRR calculation'; diff --git a/apps/database/ddl/schemas/financial/tables/01-wallets.sql b/apps/database/ddl/schemas/financial/tables/01-wallets.sql deleted file mode 100644 index a34300e..0000000 --- a/apps/database/ddl/schemas/financial/tables/01-wallets.sql +++ /dev/null @@ -1,85 +0,0 @@ --- ===================================================== --- ORBIQUANT IA - WALLETS TABLE (UNIFIED) --- ===================================================== --- Description: Single source of truth for all wallet types --- Schema: financial --- ===================================================== --- UNIFICACIÓN: Esta tabla reemplaza definiciones previas en: --- - OQI-004 (trading schema) --- - OQI-005 (investment schema) --- - OQI-008 (financial schema - legacy) --- ===================================================== - -CREATE TABLE financial.wallets ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT, - - -- Tipo y estado - wallet_type financial.wallet_type NOT NULL, - status financial.wallet_status NOT NULL DEFAULT 'active', - - -- Balance (precisión de 8 decimales para soportar crypto) - balance DECIMAL(20,8) NOT NULL DEFAULT 0.00, - available_balance DECIMAL(20,8) NOT NULL DEFAULT 0.00, - pending_balance DECIMAL(20,8) NOT NULL DEFAULT 0.00, - - -- Moneda - currency financial.currency_code NOT NULL DEFAULT 'USD', - - -- Stripe integration (si aplica) - stripe_account_id VARCHAR(255), - stripe_customer_id VARCHAR(255), - - -- Límites operacionales - daily_withdrawal_limit DECIMAL(15,2), - monthly_withdrawal_limit DECIMAL(15,2), - min_balance DECIMAL(15,2) DEFAULT 0.00, - - -- Tracking de uso - last_transaction_at TIMESTAMPTZ, - total_deposits DECIMAL(20,8) DEFAULT 0.00, - total_withdrawals DECIMAL(20,8) DEFAULT 0.00, - - -- Metadata extensible - metadata JSONB DEFAULT '{}', - - -- Timestamps - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - closed_at TIMESTAMPTZ, - - -- Constraints - CONSTRAINT positive_balance CHECK (balance >= 0), - CONSTRAINT positive_available CHECK (available_balance >= 0), - CONSTRAINT positive_pending CHECK (pending_balance >= 0), - CONSTRAINT available_lte_balance CHECK (available_balance <= balance), - CONSTRAINT balance_equation CHECK (balance = available_balance + pending_balance), - CONSTRAINT positive_limits CHECK ( - (daily_withdrawal_limit IS NULL OR daily_withdrawal_limit > 0) AND - (monthly_withdrawal_limit IS NULL OR monthly_withdrawal_limit > 0) - ), - CONSTRAINT unique_user_wallet_type UNIQUE(user_id, wallet_type, currency), - CONSTRAINT closed_status_has_date CHECK ( - (status = 'closed' AND closed_at IS NOT NULL) OR - (status != 'closed' AND closed_at IS NULL) - ) -); - --- Indexes para performance -CREATE INDEX idx_wallets_user_id ON financial.wallets(user_id); -CREATE INDEX idx_wallets_wallet_type ON financial.wallets(wallet_type); -CREATE INDEX idx_wallets_status ON financial.wallets(status) WHERE status = 'active'; -CREATE INDEX idx_wallets_currency ON financial.wallets(currency); -CREATE INDEX idx_wallets_user_type_currency ON financial.wallets(user_id, wallet_type, currency); -CREATE INDEX idx_wallets_stripe_account ON financial.wallets(stripe_account_id) WHERE stripe_account_id IS NOT NULL; -CREATE INDEX idx_wallets_last_transaction ON financial.wallets(last_transaction_at DESC NULLS LAST); - --- Comments -COMMENT ON TABLE financial.wallets IS 'Unified wallet table - single source of truth for all wallet types'; -COMMENT ON COLUMN financial.wallets.wallet_type IS 'Type of wallet: trading, investment, earnings, referral'; -COMMENT ON COLUMN financial.wallets.balance IS 'Total balance = available + pending'; -COMMENT ON COLUMN financial.wallets.available_balance IS 'Balance available for immediate use'; -COMMENT ON COLUMN financial.wallets.pending_balance IS 'Balance in pending transactions'; -COMMENT ON COLUMN financial.wallets.stripe_account_id IS 'Stripe Connect account ID for payouts'; -COMMENT ON COLUMN financial.wallets.stripe_customer_id IS 'Stripe Customer ID for payments'; -COMMENT ON COLUMN financial.wallets.metadata IS 'Extensible JSON field for additional data'; diff --git a/apps/database/ddl/schemas/financial/tables/02-wallet_transactions.sql b/apps/database/ddl/schemas/financial/tables/02-wallet_transactions.sql deleted file mode 100644 index 3ec0e62..0000000 --- a/apps/database/ddl/schemas/financial/tables/02-wallet_transactions.sql +++ /dev/null @@ -1,101 +0,0 @@ --- ===================================================== --- ORBIQUANT IA - WALLET TRANSACTIONS TABLE --- ===================================================== --- Description: Complete transaction history for all wallets --- Schema: financial --- ===================================================== - -CREATE TABLE financial.wallet_transactions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Wallet relacionada - wallet_id UUID NOT NULL REFERENCES financial.wallets(id) ON DELETE RESTRICT, - - -- Tipo y estado - transaction_type financial.transaction_type NOT NULL, - status financial.transaction_status NOT NULL DEFAULT 'pending', - - -- Montos (precisión de 8 decimales) - amount DECIMAL(20,8) NOT NULL, - fee DECIMAL(15,8) DEFAULT 0, - net_amount DECIMAL(20,8) GENERATED ALWAYS AS (amount - fee) STORED, - - -- Moneda - currency financial.currency_code NOT NULL, - - -- Balances snapshot (para auditoría) - balance_before DECIMAL(20,8), - balance_after DECIMAL(20,8), - - -- Referencias externas - stripe_payment_intent_id VARCHAR(255), - stripe_transfer_id VARCHAR(255), - stripe_charge_id VARCHAR(255), - reference_id VARCHAR(100), -- ID de referencia interna (ej: trade_id, investment_id) - - -- Para transfers entre wallets - destination_wallet_id UUID REFERENCES financial.wallets(id) ON DELETE RESTRICT, - related_transaction_id UUID REFERENCES financial.wallet_transactions(id), -- TX relacionada (para transfers bidireccionales) - - -- Descripción y notas - description TEXT, - notes TEXT, - metadata JSONB DEFAULT '{}', - - -- Procesamiento - processed_at TIMESTAMPTZ, - completed_at TIMESTAMPTZ, - failed_at TIMESTAMPTZ, - failed_reason TEXT, - - -- Idempotency - idempotency_key VARCHAR(255) UNIQUE, - - -- Timestamps - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - -- Constraints - CONSTRAINT positive_amount CHECK (amount > 0), - CONSTRAINT positive_fee CHECK (fee >= 0), - CONSTRAINT fee_lte_amount CHECK (fee <= amount), - CONSTRAINT destination_for_transfers CHECK ( - (transaction_type IN ('transfer_in', 'transfer_out') AND destination_wallet_id IS NOT NULL) OR - (transaction_type NOT IN ('transfer_in', 'transfer_out')) - ), - CONSTRAINT no_self_transfer CHECK (wallet_id != destination_wallet_id), - CONSTRAINT completed_has_timestamp CHECK ( - (status = 'completed' AND completed_at IS NOT NULL) OR - (status != 'completed') - ), - CONSTRAINT failed_has_reason CHECK ( - (status = 'failed' AND failed_reason IS NOT NULL AND failed_at IS NOT NULL) OR - (status != 'failed') - ) -); - --- Indexes para performance y queries comunes -CREATE INDEX idx_wt_wallet_id ON financial.wallet_transactions(wallet_id); -CREATE INDEX idx_wt_transaction_type ON financial.wallet_transactions(transaction_type); -CREATE INDEX idx_wt_status ON financial.wallet_transactions(status); -CREATE INDEX idx_wt_created_at ON financial.wallet_transactions(created_at DESC); -CREATE INDEX idx_wt_reference_id ON financial.wallet_transactions(reference_id) WHERE reference_id IS NOT NULL; -CREATE INDEX idx_wt_stripe_payment ON financial.wallet_transactions(stripe_payment_intent_id) WHERE stripe_payment_intent_id IS NOT NULL; -CREATE INDEX idx_wt_stripe_transfer ON financial.wallet_transactions(stripe_transfer_id) WHERE stripe_transfer_id IS NOT NULL; -CREATE INDEX idx_wt_destination ON financial.wallet_transactions(destination_wallet_id) WHERE destination_wallet_id IS NOT NULL; -CREATE INDEX idx_wt_wallet_status_created ON financial.wallet_transactions(wallet_id, status, created_at DESC); -CREATE INDEX idx_wt_idempotency ON financial.wallet_transactions(idempotency_key) WHERE idempotency_key IS NOT NULL; - --- Composite index para queries de rango por wallet -CREATE INDEX idx_wt_wallet_date_range ON financial.wallet_transactions(wallet_id, created_at DESC) - WHERE status = 'completed'; - --- Comments -COMMENT ON TABLE financial.wallet_transactions IS 'Complete transaction history for all wallet operations'; -COMMENT ON COLUMN financial.wallet_transactions.transaction_type IS 'Type of transaction: deposit, withdrawal, transfer, fee, etc.'; -COMMENT ON COLUMN financial.wallet_transactions.net_amount IS 'Amount after fees (computed column)'; -COMMENT ON COLUMN financial.wallet_transactions.reference_id IS 'Internal reference to related entity (trade, investment, etc.)'; -COMMENT ON COLUMN financial.wallet_transactions.destination_wallet_id IS 'Target wallet for transfer operations'; -COMMENT ON COLUMN financial.wallet_transactions.related_transaction_id IS 'Related transaction for bidirectional transfers'; -COMMENT ON COLUMN financial.wallet_transactions.idempotency_key IS 'Unique key to prevent duplicate transactions'; -COMMENT ON COLUMN financial.wallet_transactions.metadata IS 'Extensible JSON field for transaction-specific data'; diff --git a/apps/database/ddl/schemas/financial/tables/03-subscriptions.sql b/apps/database/ddl/schemas/financial/tables/03-subscriptions.sql deleted file mode 100644 index 483e3ca..0000000 --- a/apps/database/ddl/schemas/financial/tables/03-subscriptions.sql +++ /dev/null @@ -1,107 +0,0 @@ --- ===================================================== --- ORBIQUANT IA - SUBSCRIPTIONS TABLE --- ===================================================== --- Description: User subscription management with Stripe integration --- Schema: financial --- ===================================================== --- DECISION: Planes en USD como moneda base --- ===================================================== - -CREATE TABLE financial.subscriptions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT, - - -- Plan y estado - plan financial.subscription_plan NOT NULL, - status financial.subscription_status NOT NULL DEFAULT 'incomplete', - - -- Stripe integration - stripe_subscription_id VARCHAR(255) UNIQUE, - stripe_customer_id VARCHAR(255), - stripe_price_id VARCHAR(255), - stripe_product_id VARCHAR(255), - - -- Pricing - price DECIMAL(10,2) NOT NULL, - currency financial.currency_code NOT NULL DEFAULT 'USD', - billing_interval VARCHAR(20) NOT NULL DEFAULT 'month', -- month, year - - -- Billing periods - current_period_start TIMESTAMPTZ, - current_period_end TIMESTAMPTZ, - trial_start TIMESTAMPTZ, - trial_end TIMESTAMPTZ, - - -- Cancelación - cancelled_at TIMESTAMPTZ, - cancel_at_period_end BOOLEAN DEFAULT false, - cancellation_reason TEXT, - cancellation_feedback JSONB, - - -- Downgrade/Upgrade tracking - previous_plan financial.subscription_plan, - scheduled_plan financial.subscription_plan, - scheduled_plan_effective_at TIMESTAMPTZ, - - -- Payment tracking - last_payment_at TIMESTAMPTZ, - next_payment_at TIMESTAMPTZ, - failed_payment_count INTEGER DEFAULT 0, - - -- Features/Quotas (se pueden mover a tabla separada si crece) - metadata JSONB DEFAULT '{}', - - -- Timestamps - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - ended_at TIMESTAMPTZ, - - -- Constraints - CONSTRAINT positive_price CHECK (price >= 0), - CONSTRAINT valid_billing_interval CHECK (billing_interval IN ('month', 'year')), - CONSTRAINT trial_dates_order CHECK ( - (trial_start IS NULL AND trial_end IS NULL) OR - (trial_start IS NOT NULL AND trial_end IS NOT NULL AND trial_start < trial_end) - ), - CONSTRAINT period_dates_order CHECK ( - (current_period_start IS NULL AND current_period_end IS NULL) OR - (current_period_start IS NOT NULL AND current_period_end IS NOT NULL AND current_period_start < current_period_end) - ), - CONSTRAINT cancel_date_valid CHECK ( - (cancelled_at IS NULL) OR - (cancelled_at >= created_at) - ), - CONSTRAINT ended_when_cancelled CHECK ( - (ended_at IS NULL) OR - (cancelled_at IS NOT NULL AND ended_at >= cancelled_at) - ), - CONSTRAINT scheduled_plan_different CHECK ( - scheduled_plan IS NULL OR scheduled_plan != plan - ) -); - --- Indexes -CREATE INDEX idx_subscriptions_user_id ON financial.subscriptions(user_id); -CREATE INDEX idx_subscriptions_status ON financial.subscriptions(status); -CREATE INDEX idx_subscriptions_plan ON financial.subscriptions(plan); -CREATE INDEX idx_subscriptions_stripe_sub ON financial.subscriptions(stripe_subscription_id) WHERE stripe_subscription_id IS NOT NULL; -CREATE INDEX idx_subscriptions_stripe_customer ON financial.subscriptions(stripe_customer_id) WHERE stripe_customer_id IS NOT NULL; -CREATE INDEX idx_subscriptions_active ON financial.subscriptions(user_id, status) WHERE status = 'active'; -CREATE INDEX idx_subscriptions_period_end ON financial.subscriptions(current_period_end) WHERE status = 'active'; -CREATE INDEX idx_subscriptions_trial_end ON financial.subscriptions(trial_end) WHERE status = 'trialing'; -CREATE INDEX idx_subscriptions_next_payment ON financial.subscriptions(next_payment_at) WHERE next_payment_at IS NOT NULL; - --- Unique constraint: un usuario solo puede tener una suscripción activa a la vez -CREATE UNIQUE INDEX idx_subscriptions_user_active ON financial.subscriptions(user_id) - WHERE status IN ('active', 'trialing', 'past_due'); - --- Comments -COMMENT ON TABLE financial.subscriptions IS 'User subscription management with Stripe integration'; -COMMENT ON COLUMN financial.subscriptions.plan IS 'Subscription plan tier'; -COMMENT ON COLUMN financial.subscriptions.status IS 'Subscription status (Stripe-compatible states)'; -COMMENT ON COLUMN financial.subscriptions.price IS 'Subscription price in specified currency'; -COMMENT ON COLUMN financial.subscriptions.billing_interval IS 'Billing frequency: month or year'; -COMMENT ON COLUMN financial.subscriptions.cancel_at_period_end IS 'If true, subscription will cancel at end of current period'; -COMMENT ON COLUMN financial.subscriptions.scheduled_plan IS 'Plan to switch to at scheduled_plan_effective_at'; -COMMENT ON COLUMN financial.subscriptions.failed_payment_count IS 'Number of consecutive failed payment attempts'; -COMMENT ON COLUMN financial.subscriptions.metadata IS 'Plan features, quotas, and additional configuration'; diff --git a/apps/database/ddl/schemas/financial/tables/04-payments.sql b/apps/database/ddl/schemas/financial/tables/04-payments.sql deleted file mode 100644 index f3f6175..0000000 --- a/apps/database/ddl/schemas/financial/tables/04-payments.sql +++ /dev/null @@ -1,86 +0,0 @@ --- ===================================================== --- ORBIQUANT IA - PAYMENTS TABLE --- ===================================================== --- Description: Payment transaction records --- Schema: financial --- ===================================================== - -CREATE TABLE financial.payments ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Referencias - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT, - subscription_id UUID REFERENCES financial.subscriptions(id) ON DELETE SET NULL, - invoice_id UUID REFERENCES financial.invoices(id) ON DELETE SET NULL, - wallet_transaction_id UUID REFERENCES financial.wallet_transactions(id) ON DELETE SET NULL, - - -- Stripe integration - stripe_payment_intent_id VARCHAR(255) UNIQUE, - stripe_charge_id VARCHAR(255), - stripe_payment_method_id VARCHAR(255), - - -- Payment details - amount DECIMAL(15,2) NOT NULL, - currency financial.currency_code NOT NULL, - payment_method financial.payment_method NOT NULL, - status financial.payment_status NOT NULL DEFAULT 'pending', - - -- Descripción - description TEXT, - statement_descriptor VARCHAR(255), -- Lo que ve el usuario en su estado de cuenta - - -- Refunds - refunded BOOLEAN DEFAULT false, - refund_amount DECIMAL(15,2), - refund_reason TEXT, - refunded_at TIMESTAMPTZ, - - -- Metadata - metadata JSONB DEFAULT '{}', - - -- Error handling - failure_code VARCHAR(100), - failure_message TEXT, - - -- Timestamps - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - succeeded_at TIMESTAMPTZ, - failed_at TIMESTAMPTZ, - - -- Constraints - CONSTRAINT positive_amount CHECK (amount > 0), - CONSTRAINT positive_refund CHECK (refund_amount IS NULL OR refund_amount > 0), - CONSTRAINT refund_lte_amount CHECK (refund_amount IS NULL OR refund_amount <= amount), - CONSTRAINT refunded_has_data CHECK ( - (refunded = false AND refund_amount IS NULL AND refunded_at IS NULL) OR - (refunded = true AND refund_amount IS NOT NULL AND refunded_at IS NOT NULL) - ), - CONSTRAINT succeeded_has_timestamp CHECK ( - (status = 'succeeded' AND succeeded_at IS NOT NULL) OR - (status != 'succeeded') - ), - CONSTRAINT failed_has_data CHECK ( - (status = 'failed' AND failed_at IS NOT NULL) OR - (status != 'failed') - ) -); - --- Indexes -CREATE INDEX idx_payments_user_id ON financial.payments(user_id); -CREATE INDEX idx_payments_subscription_id ON financial.payments(subscription_id) WHERE subscription_id IS NOT NULL; -CREATE INDEX idx_payments_invoice_id ON financial.payments(invoice_id) WHERE invoice_id IS NOT NULL; -CREATE INDEX idx_payments_status ON financial.payments(status); -CREATE INDEX idx_payments_stripe_intent ON financial.payments(stripe_payment_intent_id) WHERE stripe_payment_intent_id IS NOT NULL; -CREATE INDEX idx_payments_created_at ON financial.payments(created_at DESC); -CREATE INDEX idx_payments_user_created ON financial.payments(user_id, created_at DESC); -CREATE INDEX idx_payments_payment_method ON financial.payments(payment_method); -CREATE INDEX idx_payments_refunded ON financial.payments(refunded) WHERE refunded = true; - --- Comments -COMMENT ON TABLE financial.payments IS 'Payment transaction records with Stripe integration'; -COMMENT ON COLUMN financial.payments.stripe_payment_intent_id IS 'Stripe PaymentIntent ID'; -COMMENT ON COLUMN financial.payments.payment_method IS 'Payment method used: card, bank_transfer, crypto, etc.'; -COMMENT ON COLUMN financial.payments.statement_descriptor IS 'Text shown on customer bank statement'; -COMMENT ON COLUMN financial.payments.wallet_transaction_id IS 'Related wallet transaction if payment funds a wallet'; -COMMENT ON COLUMN financial.payments.metadata IS 'Additional payment metadata and context'; diff --git a/apps/database/ddl/schemas/financial/tables/05-invoices.sql b/apps/database/ddl/schemas/financial/tables/05-invoices.sql deleted file mode 100644 index 7892a41..0000000 --- a/apps/database/ddl/schemas/financial/tables/05-invoices.sql +++ /dev/null @@ -1,120 +0,0 @@ --- ===================================================== --- ORBIQUANT IA - INVOICES TABLE --- ===================================================== --- Description: Invoice records for subscriptions and one-time charges --- Schema: financial --- ===================================================== - -CREATE TABLE financial.invoices ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Referencias - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT, - subscription_id UUID REFERENCES financial.subscriptions(id) ON DELETE SET NULL, - - -- Stripe integration - stripe_invoice_id VARCHAR(255) UNIQUE, - stripe_customer_id VARCHAR(255), - - -- Invoice details - invoice_number VARCHAR(100) UNIQUE, -- Número de factura interno - invoice_type financial.invoice_type NOT NULL, - status financial.invoice_status NOT NULL DEFAULT 'draft', - - -- Amounts - subtotal DECIMAL(15,2) NOT NULL DEFAULT 0, - tax DECIMAL(15,2) DEFAULT 0, - total DECIMAL(15,2) NOT NULL, - amount_paid DECIMAL(15,2) DEFAULT 0, - amount_due DECIMAL(15,2) GENERATED ALWAYS AS (total - amount_paid) STORED, - - currency financial.currency_code NOT NULL DEFAULT 'USD', - - -- Dates - invoice_date TIMESTAMPTZ NOT NULL DEFAULT NOW(), - due_date TIMESTAMPTZ, - period_start TIMESTAMPTZ, - period_end TIMESTAMPTZ, - - -- Payment - paid BOOLEAN DEFAULT false, - paid_at TIMESTAMPTZ, - attempted BOOLEAN DEFAULT false, - attempt_count INTEGER DEFAULT 0, - next_payment_attempt TIMESTAMPTZ, - - -- URLs - hosted_invoice_url TEXT, -- Stripe hosted invoice URL - invoice_pdf_url TEXT, - - -- Billing details - billing_email VARCHAR(255), - billing_name VARCHAR(255), - billing_address JSONB, - - -- Line items (si se quiere detalle simple) - line_items JSONB DEFAULT '[]', - - -- Metadata - description TEXT, - notes TEXT, - metadata JSONB DEFAULT '{}', - - -- Timestamps - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - finalized_at TIMESTAMPTZ, - voided_at TIMESTAMPTZ, - - -- Constraints - CONSTRAINT positive_subtotal CHECK (subtotal >= 0), - CONSTRAINT positive_tax CHECK (tax >= 0), - CONSTRAINT positive_total CHECK (total >= 0), - CONSTRAINT positive_paid CHECK (amount_paid >= 0), - CONSTRAINT paid_lte_total CHECK (amount_paid <= total), - CONSTRAINT total_equals_subtotal_plus_tax CHECK (total = subtotal + tax), - CONSTRAINT paid_has_timestamp CHECK ( - (paid = false) OR - (paid = true AND paid_at IS NOT NULL) - ), - CONSTRAINT finalized_for_open_paid CHECK ( - (status IN ('open', 'paid') AND finalized_at IS NOT NULL) OR - (status NOT IN ('open', 'paid')) - ), - CONSTRAINT void_has_timestamp CHECK ( - (status = 'void' AND voided_at IS NOT NULL) OR - (status != 'void') - ), - CONSTRAINT due_date_after_invoice CHECK ( - due_date IS NULL OR due_date >= invoice_date - ), - CONSTRAINT period_dates_order CHECK ( - (period_start IS NULL AND period_end IS NULL) OR - (period_start IS NOT NULL AND period_end IS NOT NULL AND period_start < period_end) - ) -); - --- Sequence para invoice numbers -CREATE SEQUENCE financial.invoice_number_seq START 1000; - --- Indexes -CREATE INDEX idx_invoices_user_id ON financial.invoices(user_id); -CREATE INDEX idx_invoices_subscription_id ON financial.invoices(subscription_id) WHERE subscription_id IS NOT NULL; -CREATE INDEX idx_invoices_status ON financial.invoices(status); -CREATE INDEX idx_invoices_stripe_id ON financial.invoices(stripe_invoice_id) WHERE stripe_invoice_id IS NOT NULL; -CREATE INDEX idx_invoices_invoice_number ON financial.invoices(invoice_number); -CREATE INDEX idx_invoices_due_date ON financial.invoices(due_date) WHERE due_date IS NOT NULL AND status = 'open'; -CREATE INDEX idx_invoices_invoice_date ON financial.invoices(invoice_date DESC); -CREATE INDEX idx_invoices_user_date ON financial.invoices(user_id, invoice_date DESC); -CREATE INDEX idx_invoices_unpaid ON financial.invoices(user_id, status) WHERE paid = false AND status = 'open'; -CREATE INDEX idx_invoices_period ON financial.invoices(period_start, period_end) WHERE period_start IS NOT NULL; - --- Comments -COMMENT ON TABLE financial.invoices IS 'Invoice records for subscriptions and one-time charges'; -COMMENT ON COLUMN financial.invoices.invoice_number IS 'Internal unique invoice number (auto-generated)'; -COMMENT ON COLUMN financial.invoices.invoice_type IS 'Type: subscription, one_time, or usage-based'; -COMMENT ON COLUMN financial.invoices.amount_due IS 'Computed: total - amount_paid'; -COMMENT ON COLUMN financial.invoices.line_items IS 'JSON array of invoice line items with description, amount, quantity'; -COMMENT ON COLUMN financial.invoices.billing_address IS 'JSON object with billing address details'; -COMMENT ON COLUMN financial.invoices.hosted_invoice_url IS 'URL to Stripe-hosted invoice page'; -COMMENT ON COLUMN financial.invoices.attempt_count IS 'Number of payment attempts made'; diff --git a/apps/database/ddl/schemas/financial/tables/06-wallet_audit_log.sql b/apps/database/ddl/schemas/financial/tables/06-wallet_audit_log.sql deleted file mode 100644 index 05e2547..0000000 --- a/apps/database/ddl/schemas/financial/tables/06-wallet_audit_log.sql +++ /dev/null @@ -1,68 +0,0 @@ --- ===================================================== --- ORBIQUANT IA - WALLET AUDIT LOG TABLE --- ===================================================== --- Description: Audit trail for all wallet state changes --- Schema: financial --- ===================================================== - -CREATE TABLE financial.wallet_audit_log ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Wallet referencia - wallet_id UUID NOT NULL REFERENCES financial.wallets(id) ON DELETE CASCADE, - - -- Acción - action financial.audit_action NOT NULL, - - -- Actor (quien realizó el cambio) - actor_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, - actor_type VARCHAR(50) DEFAULT 'user', -- user, system, admin, api - - -- Cambios registrados - old_values JSONB, - new_values JSONB, - - -- Balance snapshot - balance_before DECIMAL(20,8), - balance_after DECIMAL(20,8), - - -- Transacción relacionada (si aplica) - transaction_id UUID REFERENCES financial.wallet_transactions(id) ON DELETE SET NULL, - - -- Contexto - reason TEXT, - metadata JSONB DEFAULT '{}', - - -- IP y user agent (para auditoría de seguridad) - ip_address INET, - user_agent TEXT, - - -- Timestamp - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - -- Constraints - CONSTRAINT balance_change_has_amounts CHECK ( - (action = 'balance_updated' AND balance_before IS NOT NULL AND balance_after IS NOT NULL) OR - (action != 'balance_updated') - ) -); - --- Indexes -CREATE INDEX idx_wal_wallet_id ON financial.wallet_audit_log(wallet_id); -CREATE INDEX idx_wal_action ON financial.wallet_audit_log(action); -CREATE INDEX idx_wal_actor_id ON financial.wallet_audit_log(actor_id) WHERE actor_id IS NOT NULL; -CREATE INDEX idx_wal_created_at ON financial.wallet_audit_log(created_at DESC); -CREATE INDEX idx_wal_wallet_created ON financial.wallet_audit_log(wallet_id, created_at DESC); -CREATE INDEX idx_wal_transaction_id ON financial.wallet_audit_log(transaction_id) WHERE transaction_id IS NOT NULL; - --- Partitioning hint: Esta tabla puede crecer mucho, considerar particionamiento por created_at --- PARTITION BY RANGE (created_at); - --- Comments -COMMENT ON TABLE financial.wallet_audit_log IS 'Immutable audit trail for all wallet state changes'; -COMMENT ON COLUMN financial.wallet_audit_log.action IS 'Type of action performed on wallet'; -COMMENT ON COLUMN financial.wallet_audit_log.actor_id IS 'User who performed the action (NULL for system actions)'; -COMMENT ON COLUMN financial.wallet_audit_log.actor_type IS 'Type of actor: user, system, admin, api'; -COMMENT ON COLUMN financial.wallet_audit_log.old_values IS 'JSON snapshot of values before change'; -COMMENT ON COLUMN financial.wallet_audit_log.new_values IS 'JSON snapshot of values after change'; -COMMENT ON COLUMN financial.wallet_audit_log.metadata IS 'Additional context and metadata'; diff --git a/apps/database/ddl/schemas/financial/tables/07-currency_exchange_rates.sql b/apps/database/ddl/schemas/financial/tables/07-currency_exchange_rates.sql deleted file mode 100644 index 21e5e9d..0000000 --- a/apps/database/ddl/schemas/financial/tables/07-currency_exchange_rates.sql +++ /dev/null @@ -1,131 +0,0 @@ --- ===================================================== --- ORBIQUANT IA - CURRENCY EXCHANGE RATES TABLE --- ===================================================== --- Description: Historical exchange rates for multi-currency support --- Schema: financial --- ===================================================== - -CREATE TABLE financial.currency_exchange_rates ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Par de monedas - from_currency financial.currency_code NOT NULL, - to_currency financial.currency_code NOT NULL, - - -- Tasa de cambio - rate DECIMAL(18,8) NOT NULL, - - -- Fuente de datos - source VARCHAR(100) NOT NULL DEFAULT 'manual', -- manual, api, stripe, coinbase, etc. - provider VARCHAR(100), -- nombre del proveedor si es API - - -- Validez temporal - valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW(), - valid_to TIMESTAMPTZ, - - -- Metadata - metadata JSONB DEFAULT '{}', - - -- Timestamps - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - -- Constraints - CONSTRAINT positive_rate CHECK (rate > 0), - CONSTRAINT different_currencies CHECK (from_currency != to_currency), - CONSTRAINT valid_dates_order CHECK ( - valid_to IS NULL OR valid_to > valid_from - ), - CONSTRAINT unique_rate_period UNIQUE(from_currency, to_currency, valid_from) -); - --- Indexes -CREATE INDEX idx_cer_currencies ON financial.currency_exchange_rates(from_currency, to_currency); -CREATE INDEX idx_cer_valid_from ON financial.currency_exchange_rates(valid_from DESC); -CREATE INDEX idx_cer_valid_period ON financial.currency_exchange_rates(from_currency, to_currency, valid_from DESC) - WHERE valid_to IS NULL OR valid_to > NOW(); -CREATE INDEX idx_cer_source ON financial.currency_exchange_rates(source); - --- Comments -COMMENT ON TABLE financial.currency_exchange_rates IS 'Historical exchange rates for currency conversion'; -COMMENT ON COLUMN financial.currency_exchange_rates.rate IS 'Exchange rate: 1 from_currency = rate * to_currency'; -COMMENT ON COLUMN financial.currency_exchange_rates.source IS 'Source of exchange rate data'; -COMMENT ON COLUMN financial.currency_exchange_rates.valid_from IS 'Start of rate validity period'; -COMMENT ON COLUMN financial.currency_exchange_rates.valid_to IS 'End of rate validity period (NULL = currently valid)'; -COMMENT ON COLUMN financial.currency_exchange_rates.metadata IS 'Additional rate metadata (bid, ask, spread, etc.)'; - --- Función helper para obtener tasa de cambio actual -CREATE OR REPLACE FUNCTION financial.get_exchange_rate( - p_from_currency financial.currency_code, - p_to_currency financial.currency_code, - p_at_time TIMESTAMPTZ DEFAULT NOW() -) -RETURNS DECIMAL(18,8) -LANGUAGE plpgsql -STABLE -AS $$ -DECLARE - v_rate DECIMAL(18,8); -BEGIN - -- Si son la misma moneda, retornar 1 - IF p_from_currency = p_to_currency THEN - RETURN 1.0; - END IF; - - -- Buscar tasa de cambio válida - SELECT rate INTO v_rate - FROM financial.currency_exchange_rates - WHERE from_currency = p_from_currency - AND to_currency = p_to_currency - AND valid_from <= p_at_time - AND (valid_to IS NULL OR valid_to > p_at_time) - ORDER BY valid_from DESC - LIMIT 1; - - -- Si no se encuentra, intentar inversa - IF v_rate IS NULL THEN - SELECT 1.0 / rate INTO v_rate - FROM financial.currency_exchange_rates - WHERE from_currency = p_to_currency - AND to_currency = p_from_currency - AND valid_from <= p_at_time - AND (valid_to IS NULL OR valid_to > p_at_time) - ORDER BY valid_from DESC - LIMIT 1; - END IF; - - -- Si aún no hay tasa, retornar NULL - RETURN v_rate; -END; -$$; - -COMMENT ON FUNCTION financial.get_exchange_rate IS 'Get exchange rate between currencies at specific time'; - --- Función para convertir montos -CREATE OR REPLACE FUNCTION financial.convert_currency( - p_amount DECIMAL, - p_from_currency financial.currency_code, - p_to_currency financial.currency_code, - p_at_time TIMESTAMPTZ DEFAULT NOW() -) -RETURNS DECIMAL(20,8) -LANGUAGE plpgsql -STABLE -AS $$ -DECLARE - v_rate DECIMAL(18,8); -BEGIN - -- Obtener tasa de cambio - v_rate := financial.get_exchange_rate(p_from_currency, p_to_currency, p_at_time); - - -- Si no hay tasa, retornar NULL - IF v_rate IS NULL THEN - RETURN NULL; - END IF; - - -- Convertir y retornar - RETURN p_amount * v_rate; -END; -$$; - -COMMENT ON FUNCTION financial.convert_currency IS 'Convert amount between currencies at specific time'; diff --git a/apps/database/ddl/schemas/financial/tables/08-wallet_limits.sql b/apps/database/ddl/schemas/financial/tables/08-wallet_limits.sql deleted file mode 100644 index fb819c7..0000000 --- a/apps/database/ddl/schemas/financial/tables/08-wallet_limits.sql +++ /dev/null @@ -1,101 +0,0 @@ --- ===================================================== --- ORBIQUANT IA - WALLET LIMITS TABLE --- ===================================================== --- Description: Configurable limits and thresholds for wallets --- Schema: financial --- ===================================================== --- Separado de wallets para permitir límites más complejos --- y dinámicos basados en plan, nivel de verificación, etc. --- ===================================================== - -CREATE TABLE financial.wallet_limits ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Wallet o configuración global - wallet_id UUID REFERENCES financial.wallets(id) ON DELETE CASCADE, - wallet_type financial.wallet_type, -- Para límites por tipo de wallet - subscription_plan financial.subscription_plan, -- Para límites por plan - - -- Límites de transacción única - min_deposit DECIMAL(15,2), - max_deposit DECIMAL(15,2), - min_withdrawal DECIMAL(15,2), - max_withdrawal DECIMAL(15,2), - min_transfer DECIMAL(15,2), - max_transfer DECIMAL(15,2), - - -- Límites periódicos - daily_deposit_limit DECIMAL(15,2), - daily_withdrawal_limit DECIMAL(15,2), - daily_transfer_limit DECIMAL(15,2), - - weekly_deposit_limit DECIMAL(15,2), - weekly_withdrawal_limit DECIMAL(15,2), - weekly_transfer_limit DECIMAL(15,2), - - monthly_deposit_limit DECIMAL(15,2), - monthly_withdrawal_limit DECIMAL(15,2), - monthly_transfer_limit DECIMAL(15,2), - - -- Límites de volumen - max_pending_transactions INTEGER, - max_daily_transaction_count INTEGER, - - -- Balance limits - min_balance DECIMAL(15,2) DEFAULT 0, - max_balance DECIMAL(15,2), - - -- Moneda de los límites - currency financial.currency_code NOT NULL DEFAULT 'USD', - - -- Prioridad (mayor número = mayor prioridad) - priority INTEGER DEFAULT 0, - - -- Vigencia - active BOOLEAN DEFAULT true, - valid_from TIMESTAMPTZ DEFAULT NOW(), - valid_to TIMESTAMPTZ, - - -- Metadata - description TEXT, - metadata JSONB DEFAULT '{}', - - -- Timestamps - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - -- Constraints - CONSTRAINT wallet_or_type_or_plan CHECK ( - (wallet_id IS NOT NULL AND wallet_type IS NULL AND subscription_plan IS NULL) OR - (wallet_id IS NULL AND wallet_type IS NOT NULL AND subscription_plan IS NULL) OR - (wallet_id IS NULL AND wallet_type IS NULL AND subscription_plan IS NOT NULL) - ), - CONSTRAINT positive_limits CHECK ( - (min_deposit IS NULL OR min_deposit > 0) AND - (max_deposit IS NULL OR max_deposit > 0) AND - (min_withdrawal IS NULL OR min_withdrawal > 0) AND - (max_withdrawal IS NULL OR max_withdrawal > 0) AND - (min_transfer IS NULL OR min_transfer > 0) AND - (max_transfer IS NULL OR max_transfer > 0) - ), - CONSTRAINT min_max_deposit CHECK (min_deposit IS NULL OR max_deposit IS NULL OR min_deposit <= max_deposit), - CONSTRAINT min_max_withdrawal CHECK (min_withdrawal IS NULL OR max_withdrawal IS NULL OR min_withdrawal <= max_withdrawal), - CONSTRAINT min_max_transfer CHECK (min_transfer IS NULL OR max_transfer IS NULL OR min_transfer <= max_transfer), - CONSTRAINT valid_dates_order CHECK (valid_to IS NULL OR valid_to > valid_from) -); - --- Indexes -CREATE INDEX idx_wl_wallet_id ON financial.wallet_limits(wallet_id) WHERE wallet_id IS NOT NULL; -CREATE INDEX idx_wl_wallet_type ON financial.wallet_limits(wallet_type) WHERE wallet_type IS NOT NULL; -CREATE INDEX idx_wl_subscription_plan ON financial.wallet_limits(subscription_plan) WHERE subscription_plan IS NOT NULL; -CREATE INDEX idx_wl_active ON financial.wallet_limits(active, priority DESC) WHERE active = true; -CREATE INDEX idx_wl_valid_period ON financial.wallet_limits(valid_from, valid_to) - WHERE active = true AND (valid_to IS NULL OR valid_to > NOW()); - --- Comments -COMMENT ON TABLE financial.wallet_limits IS 'Configurable transaction limits for wallets'; -COMMENT ON COLUMN financial.wallet_limits.wallet_id IS 'Specific wallet (takes highest priority)'; -COMMENT ON COLUMN financial.wallet_limits.wallet_type IS 'Limits for all wallets of this type'; -COMMENT ON COLUMN financial.wallet_limits.subscription_plan IS 'Limits based on subscription plan'; -COMMENT ON COLUMN financial.wallet_limits.priority IS 'Higher number = higher priority when multiple limits apply'; -COMMENT ON COLUMN financial.wallet_limits.currency IS 'Currency for all limit amounts'; diff --git a/apps/database/ddl/schemas/financial/tables/09-customers.sql b/apps/database/ddl/schemas/financial/tables/09-customers.sql deleted file mode 100644 index 394add5..0000000 --- a/apps/database/ddl/schemas/financial/tables/09-customers.sql +++ /dev/null @@ -1,68 +0,0 @@ --- ============================================================================ --- FINANCIAL SCHEMA - Tabla: customers --- ============================================================================ --- Clientes de Stripe y datos de facturacion --- Vincula usuarios con su informacion de pago --- ============================================================================ - -CREATE TABLE IF NOT EXISTS financial.customers ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Relacion con usuario - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - - -- Stripe - stripe_customer_id VARCHAR(100) UNIQUE, - stripe_default_payment_method_id VARCHAR(100), - - -- Datos de facturacion - billing_name VARCHAR(255), - billing_email VARCHAR(255), - billing_phone VARCHAR(50), - - -- Direccion de facturacion - billing_address_line1 VARCHAR(255), - billing_address_line2 VARCHAR(255), - billing_city VARCHAR(100), - billing_state VARCHAR(100), - billing_postal_code VARCHAR(20), - billing_country VARCHAR(2), -- ISO 3166-1 alpha-2 - - -- Datos fiscales (Mexico) - tax_id VARCHAR(20), -- RFC - tax_id_type VARCHAR(20) DEFAULT 'mx_rfc', -- Tipo de ID fiscal - legal_name VARCHAR(255), -- Razon social - - -- Preferencias - currency financial.currency_code NOT NULL DEFAULT 'USD', - locale VARCHAR(10) DEFAULT 'en-US', - - -- Estado - is_active BOOLEAN NOT NULL DEFAULT TRUE, - delinquent BOOLEAN NOT NULL DEFAULT FALSE, - delinquent_since TIMESTAMPTZ, - - -- Metadata - metadata JSONB DEFAULT '{}', - - -- Timestamps - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - -- Constraints - CONSTRAINT uq_customers_user UNIQUE(user_id), - CONSTRAINT chk_valid_country CHECK (billing_country IS NULL OR LENGTH(billing_country) = 2) -); - --- Indices -CREATE INDEX idx_customers_user ON financial.customers(user_id); -CREATE INDEX idx_customers_stripe ON financial.customers(stripe_customer_id) WHERE stripe_customer_id IS NOT NULL; -CREATE INDEX idx_customers_email ON financial.customers(billing_email) WHERE billing_email IS NOT NULL; -CREATE INDEX idx_customers_delinquent ON financial.customers(delinquent) WHERE delinquent = TRUE; -CREATE INDEX idx_customers_tax_id ON financial.customers(tax_id) WHERE tax_id IS NOT NULL; - --- Comentarios -COMMENT ON TABLE financial.customers IS 'Clientes de Stripe con datos de facturacion'; -COMMENT ON COLUMN financial.customers.stripe_customer_id IS 'ID del cliente en Stripe (cus_xxx)'; -COMMENT ON COLUMN financial.customers.tax_id IS 'RFC para Mexico, VAT para EU, etc.'; -COMMENT ON COLUMN financial.customers.delinquent IS 'True si tiene pagos vencidos'; diff --git a/apps/database/ddl/schemas/financial/tables/10-payment_methods.sql b/apps/database/ddl/schemas/financial/tables/10-payment_methods.sql deleted file mode 100644 index b2cd2d1..0000000 --- a/apps/database/ddl/schemas/financial/tables/10-payment_methods.sql +++ /dev/null @@ -1,180 +0,0 @@ --- ============================================================================ --- FINANCIAL SCHEMA - Tabla: payment_methods --- ============================================================================ --- Metodos de pago guardados por usuarios para pagos recurrentes --- Integra con Stripe para almacenamiento seguro de tarjetas y cuentas --- ============================================================================ - --- Enum para tipo de metodo de pago guardado -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'saved_payment_type' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'financial')) THEN - CREATE TYPE financial.saved_payment_type AS ENUM ( - 'card', - 'bank_account', - 'sepa_debit', - 'crypto_wallet' - ); - END IF; -END$$; - --- Enum para estado del metodo de pago -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'payment_method_status' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'financial')) THEN - CREATE TYPE financial.payment_method_status AS ENUM ( - 'pending_verification', - 'active', - 'expired', - 'failed', - 'removed' - ); - END IF; -END$$; - -CREATE TABLE IF NOT EXISTS financial.payment_methods ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Relaciones - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - customer_id UUID REFERENCES financial.customers(id) ON DELETE SET NULL, - - -- Stripe integration - stripe_payment_method_id VARCHAR(100) UNIQUE, - stripe_fingerprint VARCHAR(100), -- Para detectar duplicados - - -- Tipo y estado - payment_type financial.saved_payment_type NOT NULL, - status financial.payment_method_status NOT NULL DEFAULT 'pending_verification', - - -- Informacion del metodo (datos no sensibles) - -- Para tarjetas: last4, brand, exp_month, exp_year - -- Para bancos: last4, bank_name, account_type - display_info JSONB NOT NULL DEFAULT '{}', - - -- Metodo por defecto - is_default BOOLEAN NOT NULL DEFAULT FALSE, - - -- Datos de tarjeta (solo informacion visible) - card_brand VARCHAR(20), -- 'visa', 'mastercard', 'amex', etc. - card_last4 VARCHAR(4), - card_exp_month INTEGER, - card_exp_year INTEGER, - card_funding VARCHAR(20), -- 'credit', 'debit', 'prepaid' - - -- Datos de cuenta bancaria (solo informacion visible) - bank_name VARCHAR(100), - bank_last4 VARCHAR(4), - bank_account_type VARCHAR(20), -- 'checking', 'savings' - - -- Datos de crypto wallet - crypto_network VARCHAR(20), -- 'ethereum', 'bitcoin', 'polygon' - crypto_address_last8 VARCHAR(8), - - -- Verificacion - verified_at TIMESTAMPTZ, - verification_method VARCHAR(50), -- 'micro_deposits', '3d_secure', 'instant' - - -- Billing address (para 3DS y validacion) - billing_address JSONB DEFAULT '{}', - - -- Metadata - metadata JSONB DEFAULT '{}', - - -- Timestamps - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - expires_at TIMESTAMPTZ, -- Para tarjetas con fecha de expiracion - removed_at TIMESTAMPTZ, - - -- Constraints - CONSTRAINT chk_card_info CHECK ( - payment_type != 'card' OR ( - card_brand IS NOT NULL AND - card_last4 IS NOT NULL AND - card_exp_month IS NOT NULL AND - card_exp_year IS NOT NULL - ) - ), - CONSTRAINT chk_bank_info CHECK ( - payment_type != 'bank_account' OR ( - bank_name IS NOT NULL AND - bank_last4 IS NOT NULL - ) - ), - CONSTRAINT chk_crypto_info CHECK ( - payment_type != 'crypto_wallet' OR ( - crypto_network IS NOT NULL AND - crypto_address_last8 IS NOT NULL - ) - ), - CONSTRAINT chk_valid_exp_month CHECK ( - card_exp_month IS NULL OR (card_exp_month >= 1 AND card_exp_month <= 12) - ), - CONSTRAINT chk_valid_exp_year CHECK ( - card_exp_year IS NULL OR card_exp_year >= 2024 - ) -); - --- Indices -CREATE INDEX idx_payment_methods_user ON financial.payment_methods(user_id); -CREATE INDEX idx_payment_methods_customer ON financial.payment_methods(customer_id); -CREATE INDEX idx_payment_methods_stripe ON financial.payment_methods(stripe_payment_method_id); -CREATE INDEX idx_payment_methods_status ON financial.payment_methods(status); -CREATE INDEX idx_payment_methods_default ON financial.payment_methods(user_id, is_default) - WHERE is_default = TRUE; -CREATE INDEX idx_payment_methods_fingerprint ON financial.payment_methods(stripe_fingerprint) - WHERE stripe_fingerprint IS NOT NULL; -CREATE INDEX idx_payment_methods_active ON financial.payment_methods(user_id, payment_type) - WHERE status = 'active'; - --- Comentarios -COMMENT ON TABLE financial.payment_methods IS 'Metodos de pago guardados por usuarios con integracion Stripe'; -COMMENT ON COLUMN financial.payment_methods.stripe_payment_method_id IS 'ID del PaymentMethod en Stripe'; -COMMENT ON COLUMN financial.payment_methods.stripe_fingerprint IS 'Fingerprint para detectar tarjetas duplicadas'; -COMMENT ON COLUMN financial.payment_methods.display_info IS 'Informacion visible del metodo para UI'; -COMMENT ON COLUMN financial.payment_methods.is_default IS 'Metodo de pago por defecto del usuario'; -COMMENT ON COLUMN financial.payment_methods.billing_address IS 'Direccion de facturacion para 3D Secure'; - --- Trigger para asegurar un solo metodo por defecto por usuario -CREATE OR REPLACE FUNCTION financial.ensure_single_default_payment_method() -RETURNS TRIGGER AS $$ -BEGIN - IF NEW.is_default = TRUE THEN - UPDATE financial.payment_methods - SET is_default = FALSE, updated_at = NOW() - WHERE user_id = NEW.user_id - AND id != NEW.id - AND is_default = TRUE; - END IF; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER tr_ensure_single_default_payment_method - BEFORE INSERT OR UPDATE OF is_default ON financial.payment_methods - FOR EACH ROW - WHEN (NEW.is_default = TRUE) - EXECUTE FUNCTION financial.ensure_single_default_payment_method(); - --- Funcion para marcar tarjetas expiradas -CREATE OR REPLACE FUNCTION financial.check_expired_cards() -RETURNS INTEGER AS $$ -DECLARE - v_count INTEGER; -BEGIN - UPDATE financial.payment_methods - SET status = 'expired', updated_at = NOW() - WHERE payment_type = 'card' - AND status = 'active' - AND ( - card_exp_year < EXTRACT(YEAR FROM CURRENT_DATE) OR - (card_exp_year = EXTRACT(YEAR FROM CURRENT_DATE) AND card_exp_month < EXTRACT(MONTH FROM CURRENT_DATE)) - ); - - GET DIAGNOSTICS v_count = ROW_COUNT; - RETURN v_count; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION financial.check_expired_cards() IS 'Marca como expiradas las tarjetas vencidas. Ejecutar mensualmente.'; diff --git a/apps/database/ddl/schemas/investment/00-enums.sql b/apps/database/ddl/schemas/investment/00-enums.sql deleted file mode 100644 index 689f19c..0000000 --- a/apps/database/ddl/schemas/investment/00-enums.sql +++ /dev/null @@ -1,52 +0,0 @@ --- ===================================================== --- INVESTMENT SCHEMA - ENUMS --- ===================================================== --- Description: Enumerations for PAMM investment system --- Schema: investment --- Author: Database Agent --- Date: 2025-12-06 --- ===================================================== - --- Agentes de inversión (Trading Agents) -CREATE TYPE investment.trading_agent AS ENUM ( - 'atlas', -- Conservador: 3-5% mensual - 'orion', -- Moderado: 5-10% mensual - 'nova' -- Agresivo: 10%+ mensual -); - --- Perfil de riesgo (unificado con cuestionario) -CREATE TYPE investment.risk_profile AS ENUM ( - 'conservative', - 'moderate', - 'aggressive' -); - --- Estado de cuenta PAMM -CREATE TYPE investment.account_status AS ENUM ( - 'pending_kyc', - 'active', - 'suspended', - 'closed' -); - --- Frecuencia de distribución (DECISIÓN: mensual por defecto) -CREATE TYPE investment.distribution_frequency AS ENUM ( - 'monthly', - 'quarterly' -); - --- Tipo de transacción -CREATE TYPE investment.transaction_type AS ENUM ( - 'deposit', - 'withdrawal', - 'distribution' -); - --- Estado de transacción -CREATE TYPE investment.transaction_status AS ENUM ( - 'pending', - 'processing', - 'completed', - 'failed', - 'cancelled' -); diff --git a/apps/database/ddl/schemas/investment/tables/01-products.sql b/apps/database/ddl/schemas/investment/tables/01-products.sql deleted file mode 100644 index 6bc2136..0000000 --- a/apps/database/ddl/schemas/investment/tables/01-products.sql +++ /dev/null @@ -1,60 +0,0 @@ --- ===================================================== --- INVESTMENT SCHEMA - PRODUCTS TABLE --- ===================================================== --- Description: PAMM investment products --- Schema: investment --- Author: Database Agent --- Date: 2025-12-06 --- ===================================================== - -CREATE TABLE investment.products ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Identificación - code VARCHAR(20) NOT NULL UNIQUE, -- PAMM-ATLAS, PAMM-ORION, PAMM-NOVA - name VARCHAR(100) NOT NULL, - description TEXT, - - -- Agente asociado - trading_agent investment.trading_agent NOT NULL, - - -- Parámetros de inversión - min_investment DECIMAL(15,2) NOT NULL, - max_investment DECIMAL(15,2), - - -- Rentabilidad objetivo - target_return_min DECIMAL(5,2), -- % mensual mínimo esperado - target_return_max DECIMAL(5,2), -- % mensual máximo esperado - - -- Distribución de ganancias - distribution_frequency investment.distribution_frequency DEFAULT 'monthly', - investor_share_percent DECIMAL(5,2) DEFAULT 80.00, -- 80% para inversor - platform_share_percent DECIMAL(5,2) DEFAULT 20.00, -- 20% para plataforma - - -- Perfil de riesgo recomendado - recommended_risk_profile investment.risk_profile NOT NULL, - - -- Estado - is_active BOOLEAN DEFAULT true, - is_accepting_new_investors BOOLEAN DEFAULT true, - - -- Límites - total_capacity DECIMAL(15,2), -- Capacidad total del producto - current_aum DECIMAL(15,2) DEFAULT 0.00, -- Assets Under Management - - -- Metadata - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Índices -CREATE INDEX idx_products_agent ON investment.products(trading_agent); -CREATE INDEX idx_products_active ON investment.products(is_active) WHERE is_active = true; -CREATE INDEX idx_products_risk_profile ON investment.products(recommended_risk_profile); - --- Comentarios -COMMENT ON TABLE investment.products IS 'PAMM investment products linked to trading agents'; -COMMENT ON COLUMN investment.products.code IS 'Unique product code (e.g., PAMM-ATLAS)'; -COMMENT ON COLUMN investment.products.current_aum IS 'Current Assets Under Management'; -COMMENT ON COLUMN investment.products.investor_share_percent IS 'Percentage of profits distributed to investors (80%)'; -COMMENT ON COLUMN investment.products.platform_share_percent IS 'Percentage of profits retained by platform (20%)'; diff --git a/apps/database/ddl/schemas/investment/tables/02-accounts.sql b/apps/database/ddl/schemas/investment/tables/02-accounts.sql deleted file mode 100644 index 8b906c7..0000000 --- a/apps/database/ddl/schemas/investment/tables/02-accounts.sql +++ /dev/null @@ -1,67 +0,0 @@ --- ===================================================== --- INVESTMENT SCHEMA - ACCOUNTS TABLE --- ===================================================== --- Description: Individual investor PAMM accounts --- Schema: investment --- Author: Database Agent --- Date: 2025-12-06 --- ===================================================== - -CREATE TABLE investment.accounts ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Propietario - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - - -- Producto PAMM - product_id UUID NOT NULL REFERENCES investment.products(id) ON DELETE RESTRICT, - - -- Identificación - account_number VARCHAR(20) NOT NULL UNIQUE, -- INV-202512-00001 - - -- Balance - initial_balance DECIMAL(15,2) NOT NULL, - current_balance DECIMAL(15,2) NOT NULL, - total_deposits DECIMAL(15,2) DEFAULT 0.00, - total_withdrawals DECIMAL(15,2) DEFAULT 0.00, - total_distributions DECIMAL(15,2) DEFAULT 0.00, - - -- Rentabilidad - total_return_percent DECIMAL(10,4) DEFAULT 0.00, - total_return_amount DECIMAL(15,2) DEFAULT 0.00, - - -- Perfil de riesgo del usuario - user_risk_profile investment.risk_profile NOT NULL, - questionnaire_id UUID REFERENCES investment.risk_questionnaire(id), - - -- Estado - status investment.account_status DEFAULT 'pending_kyc', - - -- KYC/Compliance - kyc_verified BOOLEAN DEFAULT false, - kyc_verified_at TIMESTAMPTZ, - kyc_verified_by VARCHAR(100), - - -- Fechas importantes - opened_at TIMESTAMPTZ, - closed_at TIMESTAMPTZ, - last_distribution_at TIMESTAMPTZ, - - -- Metadata - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Índices -CREATE INDEX idx_accounts_user ON investment.accounts(user_id); -CREATE INDEX idx_accounts_product ON investment.accounts(product_id); -CREATE INDEX idx_accounts_status ON investment.accounts(status); -CREATE INDEX idx_accounts_active ON investment.accounts(status) WHERE status = 'active'; -CREATE INDEX idx_accounts_number ON investment.accounts(account_number); - --- Comentarios -COMMENT ON TABLE investment.accounts IS 'Individual investor PAMM accounts'; -COMMENT ON COLUMN investment.accounts.account_number IS 'Unique account identifier (INV-YYYYMM-NNNNN)'; -COMMENT ON COLUMN investment.accounts.current_balance IS 'Current account balance including all deposits, withdrawals, and distributions'; -COMMENT ON COLUMN investment.accounts.total_return_percent IS 'Cumulative return percentage since account opening'; -COMMENT ON COLUMN investment.accounts.user_risk_profile IS 'Risk profile from questionnaire, must match product recommendation'; diff --git a/apps/database/ddl/schemas/investment/tables/03-transactions.sql b/apps/database/ddl/schemas/investment/tables/03-transactions.sql deleted file mode 100644 index cce6a12..0000000 --- a/apps/database/ddl/schemas/investment/tables/03-transactions.sql +++ /dev/null @@ -1,69 +0,0 @@ --- ===================================================== --- INVESTMENT SCHEMA - TRANSACTIONS TABLE --- ===================================================== --- Description: Deposits, withdrawals, and distributions --- Schema: investment --- Author: Database Agent --- Date: 2025-12-06 --- ===================================================== - -CREATE TABLE investment.transactions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Cuenta asociada - account_id UUID NOT NULL REFERENCES investment.accounts(id) ON DELETE CASCADE, - - -- Identificación - transaction_number VARCHAR(30) NOT NULL UNIQUE, -- TXN-202512-00001 - - -- Tipo y monto - transaction_type investment.transaction_type NOT NULL, - amount DECIMAL(15,2) NOT NULL CHECK (amount > 0), - - -- Estado - status investment.transaction_status DEFAULT 'pending', - - -- Detalles de pago (para deposits/withdrawals) - payment_method VARCHAR(50), -- bank_transfer, card, crypto - payment_reference VARCHAR(100), - payment_metadata JSONB, - - -- Distribución (para transaction_type = 'distribution') - distribution_id UUID REFERENCES investment.distributions(id), - - -- Balance después de transacción - balance_before DECIMAL(15,2), - balance_after DECIMAL(15,2), - - -- Procesamiento - requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - processed_at TIMESTAMPTZ, - completed_at TIMESTAMPTZ, - failed_at TIMESTAMPTZ, - failure_reason TEXT, - - -- Aprobación (para withdrawals) - requires_approval BOOLEAN DEFAULT false, - approved_by VARCHAR(100), - approved_at TIMESTAMPTZ, - - -- Metadata - notes TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Índices -CREATE INDEX idx_transactions_account ON investment.transactions(account_id); -CREATE INDEX idx_transactions_type ON investment.transactions(transaction_type); -CREATE INDEX idx_transactions_status ON investment.transactions(status); -CREATE INDEX idx_transactions_number ON investment.transactions(transaction_number); -CREATE INDEX idx_transactions_distribution ON investment.transactions(distribution_id); -CREATE INDEX idx_transactions_requested ON investment.transactions(requested_at DESC); - --- Comentarios -COMMENT ON TABLE investment.transactions IS 'All account transactions: deposits, withdrawals, and distributions'; -COMMENT ON COLUMN investment.transactions.transaction_number IS 'Unique transaction identifier (TXN-YYYYMM-NNNNN)'; -COMMENT ON COLUMN investment.transactions.payment_method IS 'Payment method for deposits/withdrawals'; -COMMENT ON COLUMN investment.transactions.distribution_id IS 'Link to distribution record if transaction_type is distribution'; -COMMENT ON COLUMN investment.transactions.requires_approval IS 'Whether withdrawal requires manual approval'; diff --git a/apps/database/ddl/schemas/investment/tables/04-distributions.sql b/apps/database/ddl/schemas/investment/tables/04-distributions.sql deleted file mode 100644 index 6ca102a..0000000 --- a/apps/database/ddl/schemas/investment/tables/04-distributions.sql +++ /dev/null @@ -1,69 +0,0 @@ --- ===================================================== --- INVESTMENT SCHEMA - DISTRIBUTIONS TABLE --- ===================================================== --- Description: Profit distributions (80/20 split) --- Schema: investment --- Author: Database Agent --- Date: 2025-12-06 --- ===================================================== - -CREATE TABLE investment.distributions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Producto PAMM - product_id UUID NOT NULL REFERENCES investment.products(id) ON DELETE RESTRICT, - - -- Periodo - period_start TIMESTAMPTZ NOT NULL, - period_end TIMESTAMPTZ NOT NULL, - period_label VARCHAR(20) NOT NULL, -- 2025-12, 2025-Q4 - - -- Performance del agente de trading - total_profit_amount DECIMAL(15,2) NOT NULL, -- Ganancia total generada - total_profit_percent DECIMAL(10,4) NOT NULL, -- % de retorno - - -- Distribución 80/20 - investor_total_amount DECIMAL(15,2) NOT NULL, -- 80% para inversores - platform_total_amount DECIMAL(15,2) NOT NULL, -- 20% para plataforma - - -- Cuentas participantes - participating_accounts INTEGER NOT NULL, - total_aum_at_period_start DECIMAL(15,2) NOT NULL, - total_aum_at_period_end DECIMAL(15,2) NOT NULL, - - -- Estado - status VARCHAR(20) DEFAULT 'pending', -- pending, processing, completed, failed - - -- Procesamiento - calculated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - approved_by VARCHAR(100), - approved_at TIMESTAMPTZ, - distributed_at TIMESTAMPTZ, - - -- Metadata - notes TEXT, - distribution_metadata JSONB, -- Detalles adicionales - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - -- Validación - CONSTRAINT valid_period CHECK (period_end > period_start), - CONSTRAINT valid_split CHECK ( - investor_total_amount + platform_total_amount = total_profit_amount - ) -); - --- Índices -CREATE INDEX idx_distributions_product ON investment.distributions(product_id); -CREATE INDEX idx_distributions_period ON investment.distributions(period_start, period_end); -CREATE INDEX idx_distributions_status ON investment.distributions(status); -CREATE UNIQUE INDEX idx_distributions_product_period ON investment.distributions(product_id, period_label); - --- Comentarios -COMMENT ON TABLE investment.distributions IS 'Periodic profit distributions with 80/20 split'; -COMMENT ON COLUMN investment.distributions.period_label IS 'Human-readable period identifier (YYYY-MM or YYYY-QN)'; -COMMENT ON COLUMN investment.distributions.total_profit_amount IS 'Total profit generated by trading agent during period'; -COMMENT ON COLUMN investment.distributions.investor_total_amount IS '80% of total profit distributed to all investors'; -COMMENT ON COLUMN investment.distributions.platform_total_amount IS '20% of total profit retained by platform'; -COMMENT ON COLUMN investment.distributions.total_aum_at_period_start IS 'Total Assets Under Management at period start'; diff --git a/apps/database/ddl/schemas/investment/tables/05-risk_questionnaire.sql b/apps/database/ddl/schemas/investment/tables/05-risk_questionnaire.sql deleted file mode 100644 index 69e96dd..0000000 --- a/apps/database/ddl/schemas/investment/tables/05-risk_questionnaire.sql +++ /dev/null @@ -1,63 +0,0 @@ --- ===================================================== --- INVESTMENT SCHEMA - RISK QUESTIONNAIRE TABLE --- ===================================================== --- Description: Risk assessment questionnaire (15 questions) --- Schema: investment --- Author: Database Agent --- Date: 2025-12-06 --- ===================================================== - -CREATE TABLE investment.risk_questionnaire ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Usuario - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - - -- Respuestas (15 preguntas) - responses JSONB NOT NULL, -- [{question_id, answer, score}] - - -- Resultado - total_score INTEGER NOT NULL CHECK (total_score >= 0 AND total_score <= 100), - calculated_profile investment.risk_profile NOT NULL, - - -- Recomendación de agente - recommended_agent investment.trading_agent, - - -- Validez - completed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - expires_at TIMESTAMPTZ NOT NULL, -- Válido por 1 año - is_expired BOOLEAN GENERATED ALWAYS AS (expires_at < NOW()) STORED, - - -- Metadata - ip_address INET, - user_agent TEXT, - completion_time_seconds INTEGER, -- Tiempo que tardó en completar - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Índices -CREATE INDEX idx_questionnaire_user ON investment.risk_questionnaire(user_id); -CREATE INDEX idx_questionnaire_profile ON investment.risk_questionnaire(calculated_profile); -CREATE INDEX idx_questionnaire_valid ON investment.risk_questionnaire(user_id, expires_at DESC) - WHERE expires_at > NOW(); - --- Comentarios -COMMENT ON TABLE investment.risk_questionnaire IS 'Risk assessment questionnaire responses (valid for 1 year)'; -COMMENT ON COLUMN investment.risk_questionnaire.responses IS 'Array of question responses with scores: [{question_id, answer, score}]'; -COMMENT ON COLUMN investment.risk_questionnaire.total_score IS 'Sum of all question scores (0-100)'; -COMMENT ON COLUMN investment.risk_questionnaire.calculated_profile IS 'Risk profile calculated from total_score'; -COMMENT ON COLUMN investment.risk_questionnaire.recommended_agent IS 'Trading agent recommendation based on risk profile'; -COMMENT ON COLUMN investment.risk_questionnaire.expires_at IS 'Questionnaire expires after 1 year, user must retake'; - --- Ejemplo de estructura de responses JSONB: -COMMENT ON COLUMN investment.risk_questionnaire.responses IS -'Example: [ - {"question_id": "Q1", "answer": "A", "score": 5}, - {"question_id": "Q2", "answer": "B", "score": 10}, - ... -] -Scoring logic: -- Conservative (0-40): Atlas agent recommended -- Moderate (41-70): Orion agent recommended -- Aggressive (71-100): Nova agent recommended'; diff --git a/apps/database/ddl/schemas/investment/tables/06-withdrawal_requests.sql b/apps/database/ddl/schemas/investment/tables/06-withdrawal_requests.sql deleted file mode 100644 index 8659bc0..0000000 --- a/apps/database/ddl/schemas/investment/tables/06-withdrawal_requests.sql +++ /dev/null @@ -1,119 +0,0 @@ --- ============================================================================ --- INVESTMENT SCHEMA - Tabla: withdrawal_requests --- ============================================================================ --- Solicitudes de retiro de cuentas PAMM --- Requiere aprobacion manual para montos grandes --- ============================================================================ - --- Enum para estado de solicitud -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'withdrawal_status' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'investment')) THEN - CREATE TYPE investment.withdrawal_status AS ENUM ( - 'pending', - 'under_review', - 'approved', - 'processing', - 'completed', - 'rejected', - 'cancelled' - ); - END IF; -END$$; - -CREATE TABLE IF NOT EXISTS investment.withdrawal_requests ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Relaciones - account_id UUID NOT NULL REFERENCES investment.accounts(id) ON DELETE RESTRICT, - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT, - - -- Solicitud - request_number VARCHAR(20) NOT NULL UNIQUE, - amount DECIMAL(20, 8) NOT NULL, - currency VARCHAR(10) NOT NULL DEFAULT 'USD', - - -- Estado - status investment.withdrawal_status NOT NULL DEFAULT 'pending', - - -- Destino del retiro - destination_type VARCHAR(20) NOT NULL, -- 'wallet', 'bank', 'crypto' - destination_details JSONB NOT NULL DEFAULT '{}', - - -- Fees - fee_amount DECIMAL(20, 8) NOT NULL DEFAULT 0, - fee_percentage DECIMAL(5, 2) NOT NULL DEFAULT 0, - net_amount DECIMAL(20, 8) GENERATED ALWAYS AS (amount - fee_amount) STORED, - - -- Aprobacion - requires_approval BOOLEAN NOT NULL DEFAULT FALSE, - reviewed_by UUID REFERENCES auth.users(id), - reviewed_at TIMESTAMPTZ, - review_notes TEXT, - - -- Procesamiento - processed_at TIMESTAMPTZ, - completed_at TIMESTAMPTZ, - transaction_reference VARCHAR(100), - - -- Rechazo/Cancelacion - rejection_reason TEXT, - cancelled_at TIMESTAMPTZ, - cancellation_reason TEXT, - - -- Metadata - ip_address INET, - user_agent TEXT, - metadata JSONB DEFAULT '{}', - - -- Timestamps - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - -- Constraints - CONSTRAINT chk_positive_amount CHECK (amount > 0), - CONSTRAINT chk_valid_fee CHECK (fee_amount >= 0 AND fee_amount <= amount), - CONSTRAINT chk_fee_percentage CHECK (fee_percentage >= 0 AND fee_percentage <= 100), - CONSTRAINT chk_destination_type CHECK (destination_type IN ('wallet', 'bank', 'crypto')) -); - --- Indices -CREATE INDEX idx_withdrawal_requests_account ON investment.withdrawal_requests(account_id); -CREATE INDEX idx_withdrawal_requests_user ON investment.withdrawal_requests(user_id); -CREATE INDEX idx_withdrawal_requests_status ON investment.withdrawal_requests(status); -CREATE INDEX idx_withdrawal_requests_created ON investment.withdrawal_requests(created_at DESC); -CREATE INDEX idx_withdrawal_requests_pending ON investment.withdrawal_requests(status, created_at) - WHERE status IN ('pending', 'under_review'); - --- Comentarios -COMMENT ON TABLE investment.withdrawal_requests IS 'Solicitudes de retiro de cuentas PAMM'; -COMMENT ON COLUMN investment.withdrawal_requests.request_number IS 'Numero unico de solicitud (WR-YYYYMMDD-XXXX)'; -COMMENT ON COLUMN investment.withdrawal_requests.requires_approval IS 'True si el monto requiere aprobacion manual'; -COMMENT ON COLUMN investment.withdrawal_requests.destination_details IS 'Detalles del destino (IBAN, wallet address, etc.)'; - --- Funcion para generar numero de solicitud -CREATE OR REPLACE FUNCTION investment.generate_withdrawal_request_number() -RETURNS TRIGGER AS $$ -DECLARE - v_date TEXT; - v_seq INTEGER; -BEGIN - v_date := TO_CHAR(NOW(), 'YYYYMMDD'); - - SELECT COALESCE(MAX( - CAST(SUBSTRING(request_number FROM 'WR-[0-9]{8}-([0-9]+)') AS INTEGER) - ), 0) + 1 - INTO v_seq - FROM investment.withdrawal_requests - WHERE request_number LIKE 'WR-' || v_date || '-%'; - - NEW.request_number := 'WR-' || v_date || '-' || LPAD(v_seq::TEXT, 4, '0'); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER tr_generate_withdrawal_request_number - BEFORE INSERT ON investment.withdrawal_requests - FOR EACH ROW - WHEN (NEW.request_number IS NULL) - EXECUTE FUNCTION investment.generate_withdrawal_request_number(); diff --git a/apps/database/ddl/schemas/investment/tables/07-daily_performance.sql b/apps/database/ddl/schemas/investment/tables/07-daily_performance.sql deleted file mode 100644 index 58fd8ba..0000000 --- a/apps/database/ddl/schemas/investment/tables/07-daily_performance.sql +++ /dev/null @@ -1,115 +0,0 @@ --- ============================================================================ --- INVESTMENT SCHEMA - Tabla: daily_performance --- ============================================================================ --- Snapshots diarios de rendimiento de cuentas PAMM --- Usado para graficos, reportes y calculo de metricas --- ============================================================================ - -CREATE TABLE IF NOT EXISTS investment.daily_performance ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Relaciones - account_id UUID NOT NULL REFERENCES investment.accounts(id) ON DELETE CASCADE, - product_id UUID NOT NULL REFERENCES investment.products(id) ON DELETE CASCADE, - - -- Fecha del snapshot - snapshot_date DATE NOT NULL, - - -- Balance - opening_balance DECIMAL(20, 8) NOT NULL, - closing_balance DECIMAL(20, 8) NOT NULL, - - -- Rendimiento del dia - daily_pnl DECIMAL(20, 8) NOT NULL DEFAULT 0, - daily_return_percentage DECIMAL(10, 6) NOT NULL DEFAULT 0, - - -- Rendimiento acumulado - cumulative_pnl DECIMAL(20, 8) NOT NULL DEFAULT 0, - cumulative_return_percentage DECIMAL(10, 6) NOT NULL DEFAULT 0, - - -- Movimientos del dia - deposits DECIMAL(20, 8) NOT NULL DEFAULT 0, - withdrawals DECIMAL(20, 8) NOT NULL DEFAULT 0, - distributions_received DECIMAL(20, 8) NOT NULL DEFAULT 0, - - -- Metricas del agente de trading - trades_executed INTEGER NOT NULL DEFAULT 0, - winning_trades INTEGER NOT NULL DEFAULT 0, - losing_trades INTEGER NOT NULL DEFAULT 0, - win_rate DECIMAL(5, 2), - - -- Volatilidad y riesgo - max_drawdown DECIMAL(10, 6), - sharpe_ratio DECIMAL(10, 6), - volatility DECIMAL(10, 6), - - -- High/Low del dia - high_water_mark DECIMAL(20, 8), - lowest_point DECIMAL(20, 8), - - -- Metadata del snapshot - snapshot_source VARCHAR(50) DEFAULT 'cron', -- 'cron', 'manual', 'system' - metadata JSONB DEFAULT '{}', - - -- Timestamps - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - -- Constraints - CONSTRAINT uq_daily_performance_account_date UNIQUE(account_id, snapshot_date), - CONSTRAINT chk_valid_balances CHECK (opening_balance >= 0 AND closing_balance >= 0), - CONSTRAINT chk_valid_movements CHECK (deposits >= 0 AND withdrawals >= 0), - CONSTRAINT chk_valid_trades CHECK ( - trades_executed >= 0 AND - winning_trades >= 0 AND - losing_trades >= 0 AND - winning_trades + losing_trades <= trades_executed - ), - CONSTRAINT chk_valid_win_rate CHECK (win_rate IS NULL OR (win_rate >= 0 AND win_rate <= 100)) -); - --- Indices -CREATE INDEX idx_daily_performance_account ON investment.daily_performance(account_id); -CREATE INDEX idx_daily_performance_product ON investment.daily_performance(product_id); -CREATE INDEX idx_daily_performance_date ON investment.daily_performance(snapshot_date DESC); -CREATE INDEX idx_daily_performance_account_date ON investment.daily_performance(account_id, snapshot_date DESC); - --- Indice parcial para ultimos 30 dias (hot data) -CREATE INDEX idx_daily_performance_recent ON investment.daily_performance(account_id, snapshot_date) - WHERE snapshot_date >= CURRENT_DATE - INTERVAL '30 days'; - --- Comentarios -COMMENT ON TABLE investment.daily_performance IS 'Snapshots diarios de rendimiento de cuentas PAMM'; -COMMENT ON COLUMN investment.daily_performance.snapshot_date IS 'Fecha del snapshot (una entrada por dia por cuenta)'; -COMMENT ON COLUMN investment.daily_performance.daily_return_percentage IS 'Retorno del dia como porcentaje'; -COMMENT ON COLUMN investment.daily_performance.cumulative_return_percentage IS 'Retorno acumulado desde apertura de cuenta'; -COMMENT ON COLUMN investment.daily_performance.max_drawdown IS 'Maximo drawdown del dia'; -COMMENT ON COLUMN investment.daily_performance.high_water_mark IS 'Punto mas alto alcanzado'; - --- Vista para resumen mensual -CREATE OR REPLACE VIEW investment.v_monthly_performance AS -SELECT - account_id, - product_id, - DATE_TRUNC('month', snapshot_date) AS month, - MIN(opening_balance) AS month_opening, - MAX(closing_balance) AS month_closing, - SUM(daily_pnl) AS total_pnl, - AVG(daily_return_percentage) AS avg_daily_return, - SUM(deposits) AS total_deposits, - SUM(withdrawals) AS total_withdrawals, - SUM(distributions_received) AS total_distributions, - SUM(trades_executed) AS total_trades, - SUM(winning_trades) AS total_winning, - SUM(losing_trades) AS total_losing, - CASE - WHEN SUM(winning_trades) + SUM(losing_trades) > 0 - THEN ROUND(SUM(winning_trades)::DECIMAL / (SUM(winning_trades) + SUM(losing_trades)) * 100, 2) - ELSE NULL - END AS monthly_win_rate, - MIN(lowest_point) AS monthly_low, - MAX(high_water_mark) AS monthly_high, - COUNT(*) AS trading_days -FROM investment.daily_performance -GROUP BY account_id, product_id, DATE_TRUNC('month', snapshot_date); - -COMMENT ON VIEW investment.v_monthly_performance IS 'Resumen mensual de rendimiento agregado'; diff --git a/apps/database/ddl/schemas/llm/00-enums.sql b/apps/database/ddl/schemas/llm/00-enums.sql deleted file mode 100644 index 107bcdb..0000000 --- a/apps/database/ddl/schemas/llm/00-enums.sql +++ /dev/null @@ -1,63 +0,0 @@ --- ===================================================== --- LLM SCHEMA - ENUMS --- ===================================================== --- Description: Enumerations for LLM agent system --- Schema: llm --- Author: Database Agent --- Date: 2025-12-06 --- ===================================================== - --- Rol del mensaje -CREATE TYPE llm.message_role AS ENUM ( - 'user', - 'assistant', - 'system', - 'tool' -); - --- Estado de la conversación -CREATE TYPE llm.conversation_status AS ENUM ( - 'active', - 'archived', - 'deleted' -); - --- Tipo de conversación -CREATE TYPE llm.conversation_type AS ENUM ( - 'general', -- Conversación general - 'trading_advice', -- Consulta sobre trading - 'education', -- Preguntas educativas - 'market_analysis', -- Análisis de mercado - 'support', -- Soporte técnico - 'onboarding' -- Onboarding de usuario -); - --- Tono de comunicación -CREATE TYPE llm.communication_tone AS ENUM ( - 'casual', - 'professional', - 'technical' -); - --- Nivel de verbosidad -CREATE TYPE llm.verbosity_level AS ENUM ( - 'brief', - 'normal', - 'detailed' -); - --- Frecuencia de alertas -CREATE TYPE llm.alert_frequency AS ENUM ( - 'low', - 'normal', - 'high' -); - --- Tipo de memoria -CREATE TYPE llm.memory_type AS ENUM ( - 'fact', -- Hecho sobre el usuario - 'preference', -- Preferencia del usuario - 'context', -- Contexto de conversaciones - 'goal', -- Objetivo del usuario - 'constraint' -- Restricción o límite -); diff --git a/apps/database/ddl/schemas/llm/tables/01-conversations.sql b/apps/database/ddl/schemas/llm/tables/01-conversations.sql deleted file mode 100644 index abac1b6..0000000 --- a/apps/database/ddl/schemas/llm/tables/01-conversations.sql +++ /dev/null @@ -1,63 +0,0 @@ --- ===================================================== --- LLM SCHEMA - CONVERSATIONS TABLE --- ===================================================== --- Description: Chat conversations with LLM agent --- Schema: llm --- Author: Database Agent --- Date: 2025-12-06 --- ===================================================== - -CREATE TABLE llm.conversations ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Usuario - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - - -- Identificación - title VARCHAR(200), -- Auto-generado o definido por usuario - - -- Tipo y contexto - conversation_type llm.conversation_type DEFAULT 'general', - - -- Estado - status llm.conversation_status DEFAULT 'active', - - -- Resumen de conversación (generado por LLM) - summary TEXT, - - -- Metadata - total_messages INTEGER DEFAULT 0, - total_tokens_used INTEGER DEFAULT 0, - - -- Tags para búsqueda - tags TEXT[] DEFAULT '{}', - - -- Contexto de negocio - related_symbols VARCHAR(20)[] DEFAULT '{}', -- Símbolos discutidos - related_topics TEXT[] DEFAULT '{}', -- Temas discutidos - - -- Fechas - started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - last_message_at TIMESTAMPTZ, - archived_at TIMESTAMPTZ, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Índices -CREATE INDEX idx_conversations_user ON llm.conversations(user_id); -CREATE INDEX idx_conversations_status ON llm.conversations(status); -CREATE INDEX idx_conversations_type ON llm.conversations(conversation_type); -CREATE INDEX idx_conversations_active ON llm.conversations(user_id, last_message_at DESC) - WHERE status = 'active'; -CREATE INDEX idx_conversations_tags ON llm.conversations USING GIN(tags); -CREATE INDEX idx_conversations_symbols ON llm.conversations USING GIN(related_symbols); - --- Comentarios -COMMENT ON TABLE llm.conversations IS 'Chat conversations between users and LLM agent'; -COMMENT ON COLUMN llm.conversations.title IS 'Conversation title (auto-generated from first messages or user-defined)'; -COMMENT ON COLUMN llm.conversations.summary IS 'AI-generated summary of conversation content'; -COMMENT ON COLUMN llm.conversations.total_tokens_used IS 'Cumulative token count for cost tracking'; -COMMENT ON COLUMN llm.conversations.related_symbols IS 'Trading symbols mentioned in conversation'; -COMMENT ON COLUMN llm.conversations.related_topics IS 'Topics discussed (e.g., technical_analysis, risk_management)'; diff --git a/apps/database/ddl/schemas/llm/tables/02-messages.sql b/apps/database/ddl/schemas/llm/tables/02-messages.sql deleted file mode 100644 index bdf4ecc..0000000 --- a/apps/database/ddl/schemas/llm/tables/02-messages.sql +++ /dev/null @@ -1,98 +0,0 @@ --- ===================================================== --- LLM SCHEMA - MESSAGES TABLE --- ===================================================== --- Description: Individual messages in conversations --- Schema: llm --- Author: Database Agent --- Date: 2025-12-06 --- ===================================================== - -CREATE TABLE llm.messages ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Conversación - conversation_id UUID NOT NULL REFERENCES llm.conversations(id) ON DELETE CASCADE, - - -- Rol y contenido - role llm.message_role NOT NULL, - content TEXT NOT NULL, - - -- Metadata de LLM - model_name VARCHAR(100), -- claude-opus-4-5, gpt-4, etc. - prompt_tokens INTEGER, - completion_tokens INTEGER, - total_tokens INTEGER, - - -- Contexto utilizado - context_used JSONB, -- RAG context, market data, user profile, etc. - - -- Tools/Functions llamadas - tool_calls JSONB, -- Function calls realizadas - tool_results JSONB, -- Resultados de tool calls - - -- Metadata de procesamiento - response_time_ms INTEGER, - temperature DECIMAL(3,2), - - -- Feedback del usuario - user_rating INTEGER CHECK (user_rating >= 1 AND user_rating <= 5), - user_feedback TEXT, - - -- Referencias - references_symbols VARCHAR(20)[] DEFAULT '{}', - references_concepts TEXT[] DEFAULT '{}', - - -- Metadata - metadata JSONB, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Índices -CREATE INDEX idx_messages_conversation ON llm.messages(conversation_id); -CREATE INDEX idx_messages_role ON llm.messages(role); -CREATE INDEX idx_messages_created ON llm.messages(created_at DESC); -CREATE INDEX idx_messages_conversation_created ON llm.messages(conversation_id, created_at ASC); -CREATE INDEX idx_messages_rated ON llm.messages(user_rating) WHERE user_rating IS NOT NULL; - --- Comentarios -COMMENT ON TABLE llm.messages IS 'Individual messages in LLM conversations'; -COMMENT ON COLUMN llm.messages.role IS 'Message sender: user, assistant, system, or tool'; -COMMENT ON COLUMN llm.messages.content IS 'Message text content'; -COMMENT ON COLUMN llm.messages.model_name IS 'LLM model used to generate response'; -COMMENT ON COLUMN llm.messages.context_used IS 'Context provided to LLM (RAG docs, market data, user profile)'; -COMMENT ON COLUMN llm.messages.tool_calls IS 'Functions/tools called by LLM during response generation'; -COMMENT ON COLUMN llm.messages.user_rating IS 'User satisfaction rating (1-5 stars)'; - --- Ejemplo de context_used JSONB: -COMMENT ON COLUMN llm.messages.context_used IS -'Example: { - "market_data": { - "symbol": "BTCUSDT", - "price": 45234.12, - "change_24h": 0.0234 - }, - "user_profile": { - "risk_profile": "moderate", - "preferred_symbols": ["BTCUSDT", "ETHUSDT"] - }, - "rag_documents": [ - {"doc_id": "123", "relevance": 0.89, "snippet": "..."}, - {"doc_id": "456", "relevance": 0.76, "snippet": "..."} - ] -}'; - --- Ejemplo de tool_calls JSONB: -COMMENT ON COLUMN llm.messages.tool_calls IS -'Example: [ - { - "tool": "get_market_data", - "params": {"symbol": "BTCUSDT", "timeframe": "1h"}, - "result": {...} - }, - { - "tool": "calculate_indicator", - "params": {"indicator": "rsi", "period": 14}, - "result": {"rsi": 65.42} - } -]'; diff --git a/apps/database/ddl/schemas/llm/tables/03-user_preferences.sql b/apps/database/ddl/schemas/llm/tables/03-user_preferences.sql deleted file mode 100644 index c5ae01a..0000000 --- a/apps/database/ddl/schemas/llm/tables/03-user_preferences.sql +++ /dev/null @@ -1,68 +0,0 @@ --- ===================================================== --- LLM SCHEMA - USER PREFERENCES TABLE --- ===================================================== --- Description: User preferences for LLM agent interactions --- Schema: llm --- Author: Database Agent --- Date: 2025-12-06 --- ===================================================== - -CREATE TABLE llm.user_preferences ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL UNIQUE REFERENCES auth.users(id) ON DELETE CASCADE, - - -- Preferencias de comunicación - language VARCHAR(5) DEFAULT 'es', -- ISO 639-1 - tone llm.communication_tone DEFAULT 'professional', - verbosity llm.verbosity_level DEFAULT 'normal', - - -- Preferencias de trading - preferred_symbols VARCHAR(20)[] DEFAULT '{}', - preferred_timeframe VARCHAR(10) DEFAULT '1h', - risk_tolerance VARCHAR(20) DEFAULT 'moderate', -- conservative, moderate, aggressive - - -- Preferencias de notificación - proactive_alerts BOOLEAN DEFAULT true, - alert_frequency llm.alert_frequency DEFAULT 'normal', - notification_hours_start TIME DEFAULT '08:00:00', - notification_hours_end TIME DEFAULT '22:00:00', - timezone VARCHAR(50) DEFAULT 'America/Mexico_City', - - -- Intereses - topics_of_interest TEXT[] DEFAULT '{}', -- trading, education, news, market_analysis - - -- Nivel de experiencia (para personalizar explicaciones) - trading_experience_level VARCHAR(20) DEFAULT 'beginner', -- beginner, intermediate, advanced, expert - - -- Preferencias de análisis - preferred_analysis_types TEXT[] DEFAULT '{}', -- technical, fundamental, sentiment, onchain - - -- Formato de respuestas - include_charts BOOLEAN DEFAULT true, - include_data_tables BOOLEAN DEFAULT true, - include_explanations BOOLEAN DEFAULT true, - - -- Metadata - onboarding_completed BOOLEAN DEFAULT false, - onboarding_completed_at TIMESTAMPTZ, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Índices -CREATE UNIQUE INDEX idx_user_preferences_user ON llm.user_preferences(user_id); -CREATE INDEX idx_user_preferences_language ON llm.user_preferences(language); - --- Comentarios -COMMENT ON TABLE llm.user_preferences IS 'User preferences for personalized LLM agent interactions'; -COMMENT ON COLUMN llm.user_preferences.language IS 'Preferred language for responses (ISO 639-1 code)'; -COMMENT ON COLUMN llm.user_preferences.tone IS 'Communication style: casual, professional, or technical'; -COMMENT ON COLUMN llm.user_preferences.verbosity IS 'Response length preference: brief, normal, or detailed'; -COMMENT ON COLUMN llm.user_preferences.preferred_symbols IS 'Trading pairs user is most interested in'; -COMMENT ON COLUMN llm.user_preferences.proactive_alerts IS 'Whether agent should send proactive notifications'; -COMMENT ON COLUMN llm.user_preferences.alert_frequency IS 'How often to receive alerts: low, normal, or high'; -COMMENT ON COLUMN llm.user_preferences.notification_hours_start IS 'Start of notification window in user timezone'; -COMMENT ON COLUMN llm.user_preferences.notification_hours_end IS 'End of notification window in user timezone'; -COMMENT ON COLUMN llm.user_preferences.topics_of_interest IS 'Topics user wants to learn about or discuss'; -COMMENT ON COLUMN llm.user_preferences.trading_experience_level IS 'User experience level for tailoring explanations'; diff --git a/apps/database/ddl/schemas/llm/tables/04-user_memory.sql b/apps/database/ddl/schemas/llm/tables/04-user_memory.sql deleted file mode 100644 index 4d4f6b4..0000000 --- a/apps/database/ddl/schemas/llm/tables/04-user_memory.sql +++ /dev/null @@ -1,82 +0,0 @@ --- ===================================================== --- LLM SCHEMA - USER MEMORY TABLE --- ===================================================== --- Description: Persistent memory about users for personalization --- Schema: llm --- Author: Database Agent --- Date: 2025-12-06 --- ===================================================== - -CREATE TABLE llm.user_memory ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - - -- Tipo de memoria - memory_type llm.memory_type NOT NULL, - - -- Contenido - key VARCHAR(200) NOT NULL, - value TEXT NOT NULL, - - -- Importancia - importance_score DECIMAL(3,2) DEFAULT 0.50 CHECK (importance_score >= 0.00 AND importance_score <= 1.00), - - -- Fuente - source_conversation_id UUID REFERENCES llm.conversations(id) ON DELETE SET NULL, - extracted_from TEXT, -- Fragmento del que se extrajo la memoria - extraction_method VARCHAR(50) DEFAULT 'llm', -- llm, manual, system - - -- Validez - is_active BOOLEAN DEFAULT true, - expires_at TIMESTAMPTZ, - - -- Confirmación (algunas memorias pueden requerir confirmación del usuario) - requires_confirmation BOOLEAN DEFAULT false, - confirmed_by_user BOOLEAN DEFAULT false, - confirmed_at TIMESTAMPTZ, - - -- Metadata - metadata JSONB, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - -- Constraints - CONSTRAINT unique_user_memory_key UNIQUE(user_id, memory_type, key) -); - --- Índices -CREATE INDEX idx_memory_user ON llm.user_memory(user_id); -CREATE INDEX idx_memory_type ON llm.user_memory(memory_type); -CREATE INDEX idx_memory_active ON llm.user_memory(is_active) WHERE is_active = true; -CREATE INDEX idx_memory_importance ON llm.user_memory(importance_score DESC); -CREATE INDEX idx_memory_conversation ON llm.user_memory(source_conversation_id); -CREATE INDEX idx_memory_expires ON llm.user_memory(expires_at) WHERE expires_at IS NOT NULL; - --- Comentarios -COMMENT ON TABLE llm.user_memory IS 'Persistent memory about users for LLM personalization and context'; -COMMENT ON COLUMN llm.user_memory.memory_type IS 'Type of memory: fact, preference, context, goal, or constraint'; -COMMENT ON COLUMN llm.user_memory.key IS 'Memory identifier (e.g., "favorite_symbol", "trading_goal", "risk_limit")'; -COMMENT ON COLUMN llm.user_memory.value IS 'Memory content (e.g., "BTCUSDT", "save for house", "max 5% per trade")'; -COMMENT ON COLUMN llm.user_memory.importance_score IS 'Importance weight (0.00-1.00) for retrieval prioritization'; -COMMENT ON COLUMN llm.user_memory.extracted_from IS 'Original text from which memory was extracted'; -COMMENT ON COLUMN llm.user_memory.requires_confirmation IS 'Whether this memory needs explicit user confirmation'; - --- Ejemplos de memorias por tipo: -COMMENT ON COLUMN llm.user_memory.memory_type IS -'Memory type examples: -- fact: "trading_since" = "2020", "max_loss_experienced" = "15%" -- preference: "favorite_indicator" = "RSI", "avoids_margin_trading" = "true" -- context: "recent_portfolio_loss" = "trying to recover", "learning_focus" = "risk management" -- goal: "monthly_target" = "5% return", "learning_goal" = "master technical analysis" -- constraint: "max_risk_per_trade" = "2%", "no_trading_during_work" = "9am-5pm"'; - --- Ejemplo de metadata JSONB: -COMMENT ON COLUMN llm.user_memory.metadata IS -'Example: { - "confidence": 0.85, - "last_mentioned": "2025-12-05T10:30:00Z", - "mention_count": 5, - "related_memories": ["mem_123", "mem_456"], - "tags": ["trading_style", "risk_management"] -}'; diff --git a/apps/database/ddl/schemas/llm/tables/05-embeddings.sql b/apps/database/ddl/schemas/llm/tables/05-embeddings.sql deleted file mode 100644 index d913ea8..0000000 --- a/apps/database/ddl/schemas/llm/tables/05-embeddings.sql +++ /dev/null @@ -1,122 +0,0 @@ --- ===================================================== --- LLM SCHEMA - EMBEDDINGS TABLE --- ===================================================== --- Description: Vector embeddings for RAG and semantic search --- Schema: llm --- Author: Database Agent --- Date: 2025-12-06 --- ===================================================== - --- NOTA: Requiere extensión pgvector --- CREATE EXTENSION IF NOT EXISTS vector; - -CREATE TABLE llm.embeddings ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Tipo de contenido - content_type VARCHAR(50) NOT NULL, -- message, document, faq, tutorial, article - - -- Referencia al contenido original - content_id UUID, -- ID del mensaje, documento, etc. - - -- Contenido - content TEXT NOT NULL, - content_hash VARCHAR(64), -- SHA-256 para deduplicación - - -- Metadata del contenido - title VARCHAR(500), - description TEXT, - - -- Vector embedding (dimensión depende del modelo) - -- OpenAI text-embedding-3-small: 1536 dims - -- OpenAI text-embedding-3-large: 3072 dims - -- Voyage AI: 1024 dims - embedding vector(1536), -- Ajustar según modelo usado - - -- Modelo usado para generar embedding - embedding_model VARCHAR(100) NOT NULL, -- text-embedding-3-small, voyage-2, etc. - - -- Metadata para filtrado - user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE, -- Si es contenido específico de usuario - is_public BOOLEAN DEFAULT true, - - -- Categorización - category VARCHAR(100), -- education, trading, market_news, platform_help - subcategory VARCHAR(100), - tags TEXT[] DEFAULT '{}', - - -- Relevancia - importance_score DECIMAL(3,2) DEFAULT 0.50, - - -- Contexto adicional - context_metadata JSONB, -- Metadata adicional para mejorar recuperación - - -- Fuente - source_url VARCHAR(500), - source_type VARCHAR(50), -- internal, external, generated - - -- Validez - is_active BOOLEAN DEFAULT true, - expires_at TIMESTAMPTZ, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Índices -CREATE INDEX idx_embeddings_type ON llm.embeddings(content_type); -CREATE INDEX idx_embeddings_user ON llm.embeddings(user_id) WHERE user_id IS NOT NULL; -CREATE INDEX idx_embeddings_category ON llm.embeddings(category); -CREATE INDEX idx_embeddings_tags ON llm.embeddings USING GIN(tags); -CREATE INDEX idx_embeddings_active ON llm.embeddings(is_active) WHERE is_active = true; -CREATE INDEX idx_embeddings_hash ON llm.embeddings(content_hash); - --- Índice para búsqueda vectorial (HNSW para mejor performance) --- Requiere pgvector -CREATE INDEX idx_embeddings_vector_hnsw ON llm.embeddings - USING hnsw (embedding vector_cosine_ops); - --- Índice alternativo: IVFFlat (más rápido de construir, menos preciso) --- CREATE INDEX idx_embeddings_vector_ivfflat ON llm.embeddings --- USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100); - --- Comentarios -COMMENT ON TABLE llm.embeddings IS 'Vector embeddings for RAG and semantic search using pgvector'; -COMMENT ON COLUMN llm.embeddings.content_type IS 'Type of content: message, document, faq, tutorial, article'; -COMMENT ON COLUMN llm.embeddings.content_id IS 'Reference to original content (e.g., message ID)'; -COMMENT ON COLUMN llm.embeddings.content IS 'Text content that was embedded'; -COMMENT ON COLUMN llm.embeddings.content_hash IS 'SHA-256 hash for deduplication'; -COMMENT ON COLUMN llm.embeddings.embedding IS 'Vector embedding (dimension depends on model)'; -COMMENT ON COLUMN llm.embeddings.embedding_model IS 'Model used to generate embedding'; -COMMENT ON COLUMN llm.embeddings.is_public IS 'Whether embedding is accessible to all users or user-specific'; -COMMENT ON COLUMN llm.embeddings.importance_score IS 'Relevance score for retrieval prioritization'; - --- Ejemplo de uso para búsqueda semántica: -COMMENT ON TABLE llm.embeddings IS -'Vector search example: -SELECT - content, - title, - 1 - (embedding <=> query_embedding) AS similarity -FROM llm.embeddings -WHERE is_active = true - AND category = ''education'' -ORDER BY embedding <=> query_embedding -LIMIT 5; - -Operators: -- <-> : L2 distance -- <#> : inner product -- <=> : cosine distance (recommended)'; - --- Ejemplo de context_metadata JSONB: -COMMENT ON COLUMN llm.embeddings.context_metadata IS -'Example: { - "language": "es", - "difficulty_level": "beginner", - "reading_time_minutes": 5, - "author": "system", - "last_updated": "2025-12-01", - "related_symbols": ["BTCUSDT"], - "related_topics": ["technical_analysis", "rsi"] -}'; diff --git a/apps/database/ddl/schemas/ml/00-enums.sql b/apps/database/ddl/schemas/ml/00-enums.sql deleted file mode 100644 index 7b01f33..0000000 --- a/apps/database/ddl/schemas/ml/00-enums.sql +++ /dev/null @@ -1,68 +0,0 @@ --- ===================================================== --- ML SCHEMA - ENUMS --- ===================================================== --- Description: Enumerations for ML signals system --- Schema: ml --- Author: Database Agent --- Date: 2025-12-06 --- ===================================================== - --- Tipo de modelo ML -CREATE TYPE ml.model_type AS ENUM ( - 'classification', - 'regression', - 'time_series', - 'clustering', - 'anomaly_detection', - 'reinforcement_learning' -); - --- Framework de ML -CREATE TYPE ml.framework AS ENUM ( - 'sklearn', - 'tensorflow', - 'pytorch', - 'xgboost', - 'lightgbm', - 'prophet', - 'custom' -); - --- Estado del modelo -CREATE TYPE ml.model_status AS ENUM ( - 'development', - 'testing', - 'staging', - 'production', - 'deprecated', - 'archived' -); - --- Tipo de predicción -CREATE TYPE ml.prediction_type AS ENUM ( - 'price_direction', -- UP/DOWN/NEUTRAL - 'price_target', -- Precio objetivo - 'volatility', -- Alta/Media/Baja - 'trend', -- Tendencia - 'signal', -- BUY/SELL/HOLD - 'risk_score' -- Score de riesgo -); - --- Resultado de predicción -CREATE TYPE ml.prediction_result AS ENUM ( - 'buy', - 'sell', - 'hold', - 'up', - 'down', - 'neutral' -); - --- Estado de outcome -CREATE TYPE ml.outcome_status AS ENUM ( - 'pending', - 'correct', - 'incorrect', - 'partially_correct', - 'expired' -); diff --git a/apps/database/ddl/schemas/ml/tables/01-models.sql b/apps/database/ddl/schemas/ml/tables/01-models.sql deleted file mode 100644 index d47077e..0000000 --- a/apps/database/ddl/schemas/ml/tables/01-models.sql +++ /dev/null @@ -1,65 +0,0 @@ --- ===================================================== --- ML SCHEMA - MODELS TABLE --- ===================================================== --- Description: ML models registry --- Schema: ml --- Author: Database Agent --- Date: 2025-12-06 --- ===================================================== - -CREATE TABLE ml.models ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Identificación - name VARCHAR(100) NOT NULL UNIQUE, - display_name VARCHAR(200) NOT NULL, - description TEXT, - - -- Tipo y framework - model_type ml.model_type NOT NULL, - framework ml.framework NOT NULL, - - -- Categoría - category VARCHAR(50) NOT NULL, -- sentiment, technical, fundamental, hybrid - - -- Alcance - applies_to_symbols VARCHAR(20)[] DEFAULT '{}', -- ['BTCUSDT', 'ETHUSDT'] o [] para todos - applies_to_timeframes VARCHAR(10)[] DEFAULT '{}', -- ['1h', '4h', '1d'] o [] para todos - - -- Estado - status ml.model_status DEFAULT 'development', - - -- Versión actual en producción - current_version_id UUID, - - -- Metadata - owner VARCHAR(100) NOT NULL, - repository_url VARCHAR(500), - documentation_url VARCHAR(500), - - -- Métricas agregadas (de todas las versiones) - total_predictions INTEGER DEFAULT 0, - total_correct_predictions INTEGER DEFAULT 0, - overall_accuracy DECIMAL(5,4), - - -- Fechas - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - deployed_at TIMESTAMPTZ, - deprecated_at TIMESTAMPTZ -); - --- Índices -CREATE INDEX idx_models_name ON ml.models(name); -CREATE INDEX idx_models_status ON ml.models(status); -CREATE INDEX idx_models_type ON ml.models(model_type); -CREATE INDEX idx_models_category ON ml.models(category); -CREATE INDEX idx_models_production ON ml.models(status) WHERE status = 'production'; - --- Comentarios -COMMENT ON TABLE ml.models IS 'Registry of ML models for trading signals'; -COMMENT ON COLUMN ml.models.name IS 'Unique technical name (e.g., sentiment_analyzer_v1)'; -COMMENT ON COLUMN ml.models.applies_to_symbols IS 'Symbols this model can analyze. Empty array = all symbols'; -COMMENT ON COLUMN ml.models.applies_to_timeframes IS 'Timeframes this model supports. Empty array = all timeframes'; -COMMENT ON COLUMN ml.models.current_version_id IS 'Reference to current production version'; -COMMENT ON COLUMN ml.models.overall_accuracy IS 'Aggregated accuracy across all versions and predictions'; diff --git a/apps/database/ddl/schemas/ml/tables/02-model_versions.sql b/apps/database/ddl/schemas/ml/tables/02-model_versions.sql deleted file mode 100644 index 255b677..0000000 --- a/apps/database/ddl/schemas/ml/tables/02-model_versions.sql +++ /dev/null @@ -1,102 +0,0 @@ --- ===================================================== --- ML SCHEMA - MODEL VERSIONS TABLE --- ===================================================== --- Description: Versioned ML model artifacts and metadata --- Schema: ml --- Author: Database Agent --- Date: 2025-12-06 --- ===================================================== - -CREATE TABLE ml.model_versions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Modelo padre - model_id UUID NOT NULL REFERENCES ml.models(id) ON DELETE CASCADE, - - -- Versión - version VARCHAR(50) NOT NULL, -- Semantic versioning: 1.0.0, 1.1.0, 2.0.0 - - -- Artefacto - artifact_path VARCHAR(500) NOT NULL, -- S3 path, local path, registry URL - artifact_size_bytes BIGINT, - checksum VARCHAR(64), -- SHA-256 - - -- Métricas de entrenamiento - training_metrics JSONB, -- {accuracy, precision, recall, f1, loss} - validation_metrics JSONB, -- {accuracy, precision, recall, f1, loss} - test_metrics JSONB, -- {accuracy, precision, recall, f1, loss} - - -- Features - feature_set JSONB NOT NULL, -- Lista de features usadas - feature_importance JSONB, -- Importancia de cada feature - - -- Hiperparámetros - hyperparameters JSONB, -- Parámetros del modelo - - -- Dataset info - training_dataset_size INTEGER, - training_dataset_path VARCHAR(500), - data_version VARCHAR(50), - - -- Estado - is_production BOOLEAN DEFAULT false, - deployed_at TIMESTAMPTZ, - deployment_metadata JSONB, - - -- Metadata de entrenamiento - training_started_at TIMESTAMPTZ, - training_completed_at TIMESTAMPTZ, - training_duration_seconds INTEGER, - trained_by VARCHAR(100), - training_environment JSONB, -- Python version, library versions, etc. - - -- Performance en producción - production_predictions INTEGER DEFAULT 0, - production_accuracy DECIMAL(5,4), - - -- Notas - release_notes TEXT, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - -- Constraints - CONSTRAINT unique_model_version UNIQUE(model_id, version) -); - --- Índices -CREATE INDEX idx_model_versions_model ON ml.model_versions(model_id); -CREATE INDEX idx_model_versions_production ON ml.model_versions(is_production) - WHERE is_production = true; -CREATE INDEX idx_model_versions_version ON ml.model_versions(version); -CREATE INDEX idx_model_versions_deployed ON ml.model_versions(deployed_at DESC); - --- Comentarios -COMMENT ON TABLE ml.model_versions IS 'Versioned artifacts and metadata for ML models'; -COMMENT ON COLUMN ml.model_versions.version IS 'Semantic version (major.minor.patch)'; -COMMENT ON COLUMN ml.model_versions.artifact_path IS 'Location of serialized model file'; -COMMENT ON COLUMN ml.model_versions.checksum IS 'SHA-256 hash for artifact integrity verification'; -COMMENT ON COLUMN ml.model_versions.feature_set IS 'Array of feature names used by this version'; -COMMENT ON COLUMN ml.model_versions.hyperparameters IS 'Model hyperparameters for reproducibility'; -COMMENT ON COLUMN ml.model_versions.is_production IS 'Whether this version is currently deployed in production'; - --- Ejemplo de training_metrics JSONB: -COMMENT ON COLUMN ml.model_versions.training_metrics IS -'Example: { - "accuracy": 0.8542, - "precision": 0.8234, - "recall": 0.7891, - "f1_score": 0.8058, - "loss": 0.3421, - "auc_roc": 0.9123 -}'; - --- Ejemplo de feature_set JSONB: -COMMENT ON COLUMN ml.model_versions.feature_set IS -'Example: [ - "rsi_14", - "macd_signal", - "volume_sma_20", - "price_change_1h", - "sentiment_score" -]'; diff --git a/apps/database/ddl/schemas/ml/tables/03-predictions.sql b/apps/database/ddl/schemas/ml/tables/03-predictions.sql deleted file mode 100644 index dea1663..0000000 --- a/apps/database/ddl/schemas/ml/tables/03-predictions.sql +++ /dev/null @@ -1,93 +0,0 @@ --- ===================================================== --- ML SCHEMA - PREDICTIONS TABLE --- ===================================================== --- Description: ML model predictions and signals --- Schema: ml --- Author: Database Agent --- Date: 2025-12-06 --- ===================================================== - -CREATE TABLE ml.predictions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Modelo y versión - model_id UUID NOT NULL REFERENCES ml.models(id) ON DELETE CASCADE, - model_version_id UUID NOT NULL REFERENCES ml.model_versions(id) ON DELETE CASCADE, - - -- Símbolo y timeframe - symbol VARCHAR(20) NOT NULL, - timeframe VARCHAR(10) NOT NULL, - - -- Tipo de predicción - prediction_type ml.prediction_type NOT NULL, - - -- Resultado de predicción - prediction_result ml.prediction_result, - prediction_value DECIMAL(20,8), -- Para predicciones numéricas - - -- Confianza - confidence_score DECIMAL(5,4) NOT NULL CHECK (confidence_score >= 0 AND confidence_score <= 1), - - -- Input features utilizados - input_features JSONB NOT NULL, - - -- Output completo del modelo - model_output JSONB, -- Raw output del modelo - - -- Contexto de mercado al momento de predicción - market_price DECIMAL(20,8), - market_timestamp TIMESTAMPTZ NOT NULL, - - -- Horizonte temporal - prediction_horizon VARCHAR(20), -- 1h, 4h, 1d, 1w - valid_until TIMESTAMPTZ, - - -- Metadata - prediction_metadata JSONB, - - -- Procesamiento - inference_time_ms INTEGER, -- Tiempo de inferencia en milisegundos - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Índices -CREATE INDEX idx_predictions_model ON ml.predictions(model_id); -CREATE INDEX idx_predictions_version ON ml.predictions(model_version_id); -CREATE INDEX idx_predictions_symbol ON ml.predictions(symbol); -CREATE INDEX idx_predictions_symbol_time ON ml.predictions(symbol, market_timestamp DESC); -CREATE INDEX idx_predictions_type ON ml.predictions(prediction_type); -CREATE INDEX idx_predictions_created ON ml.predictions(created_at DESC); -CREATE INDEX idx_predictions_valid ON ml.predictions(valid_until) - WHERE valid_until IS NOT NULL AND valid_until > NOW(); - --- Particionamiento por fecha (opcional, para alto volumen) --- CREATE INDEX idx_predictions_timestamp ON ml.predictions(market_timestamp DESC); - --- Comentarios -COMMENT ON TABLE ml.predictions IS 'ML model predictions and trading signals'; -COMMENT ON COLUMN ml.predictions.prediction_type IS 'Type of prediction being made'; -COMMENT ON COLUMN ml.predictions.prediction_result IS 'Categorical result (buy/sell/hold/up/down/neutral)'; -COMMENT ON COLUMN ml.predictions.prediction_value IS 'Numeric prediction value (e.g., target price, probability)'; -COMMENT ON COLUMN ml.predictions.confidence_score IS 'Model confidence in prediction (0.0 to 1.0)'; -COMMENT ON COLUMN ml.predictions.input_features IS 'Feature values used for this prediction'; -COMMENT ON COLUMN ml.predictions.prediction_horizon IS 'Time horizon for prediction validity'; -COMMENT ON COLUMN ml.predictions.inference_time_ms IS 'Model inference latency in milliseconds'; - --- Ejemplo de input_features JSONB: -COMMENT ON COLUMN ml.predictions.input_features IS -'Example: { - "rsi_14": 65.42, - "macd_signal": 0.0234, - "volume_sma_20": 1234567.89, - "price_change_1h": 0.0145, - "sentiment_score": 0.72 -}'; - --- Ejemplo de model_output JSONB: -COMMENT ON COLUMN ml.predictions.model_output IS -'Example: { - "probabilities": {"buy": 0.72, "sell": 0.15, "hold": 0.13}, - "raw_score": 0.5823, - "feature_contributions": {...} -}'; diff --git a/apps/database/ddl/schemas/ml/tables/04-prediction_outcomes.sql b/apps/database/ddl/schemas/ml/tables/04-prediction_outcomes.sql deleted file mode 100644 index 40322a8..0000000 --- a/apps/database/ddl/schemas/ml/tables/04-prediction_outcomes.sql +++ /dev/null @@ -1,68 +0,0 @@ --- ===================================================== --- ML SCHEMA - PREDICTION OUTCOMES TABLE --- ===================================================== --- Description: Actual outcomes vs predictions for model evaluation --- Schema: ml --- Author: Database Agent --- Date: 2025-12-06 --- ===================================================== - -CREATE TABLE ml.prediction_outcomes ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Predicción asociada - prediction_id UUID NOT NULL REFERENCES ml.predictions(id) ON DELETE CASCADE, - - -- Resultado real - actual_result ml.prediction_result, - actual_value DECIMAL(20,8), - - -- Evaluación - outcome_status ml.outcome_status NOT NULL DEFAULT 'pending', - is_correct BOOLEAN, - - -- Métricas de error - absolute_error DECIMAL(20,8), -- |predicted - actual| - relative_error DECIMAL(10,6), -- (predicted - actual) / actual - - -- Contexto de mercado en verificación - actual_price DECIMAL(20,8), - price_change_percent DECIMAL(10,6), - - -- Timing - outcome_timestamp TIMESTAMPTZ NOT NULL, -- Cuando se verificó el outcome - time_to_outcome_hours DECIMAL(10,2), -- Horas desde predicción hasta outcome - - -- Metadata - outcome_metadata JSONB, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - -- Constraints - CONSTRAINT unique_prediction_outcome UNIQUE(prediction_id) -); - --- Índices -CREATE INDEX idx_outcomes_prediction ON ml.prediction_outcomes(prediction_id); -CREATE INDEX idx_outcomes_status ON ml.prediction_outcomes(outcome_status); -CREATE INDEX idx_outcomes_correct ON ml.prediction_outcomes(is_correct) - WHERE is_correct IS NOT NULL; -CREATE INDEX idx_outcomes_timestamp ON ml.prediction_outcomes(outcome_timestamp DESC); - --- Comentarios -COMMENT ON TABLE ml.prediction_outcomes IS 'Actual results for model predictions, used for performance tracking'; -COMMENT ON COLUMN ml.prediction_outcomes.actual_result IS 'What actually happened (buy/sell/hold/up/down/neutral)'; -COMMENT ON COLUMN ml.prediction_outcomes.actual_value IS 'Actual numeric value (e.g., actual price reached)'; -COMMENT ON COLUMN ml.prediction_outcomes.is_correct IS 'Whether prediction matched actual outcome'; -COMMENT ON COLUMN ml.prediction_outcomes.absolute_error IS 'Absolute difference between predicted and actual'; -COMMENT ON COLUMN ml.prediction_outcomes.relative_error IS 'Percentage error relative to actual value'; -COMMENT ON COLUMN ml.prediction_outcomes.time_to_outcome_hours IS 'Time elapsed from prediction to outcome verification'; - --- Ejemplo de outcome_metadata JSONB: -COMMENT ON COLUMN ml.prediction_outcomes.outcome_metadata IS -'Example: { - "verification_method": "automated", - "market_volatility": "high", - "external_events": ["fed_announcement"], - "notes": "Prediction incorrect due to unexpected news" -}'; diff --git a/apps/database/ddl/schemas/ml/tables/05-feature_store.sql b/apps/database/ddl/schemas/ml/tables/05-feature_store.sql deleted file mode 100644 index 67b1b32..0000000 --- a/apps/database/ddl/schemas/ml/tables/05-feature_store.sql +++ /dev/null @@ -1,120 +0,0 @@ --- ===================================================== --- ML SCHEMA - FEATURE STORE TABLE --- ===================================================== --- Description: Pre-calculated features for ML models --- Schema: ml --- Author: Database Agent --- Date: 2025-12-06 --- ===================================================== - -CREATE TABLE ml.feature_store ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Identificación del feature set - symbol VARCHAR(20) NOT NULL, - timeframe VARCHAR(10) NOT NULL, - timestamp TIMESTAMPTZ NOT NULL, - - -- Features técnicos - technical_features JSONB, -- RSI, MACD, Bollinger, SMA, EMA, etc. - - -- Features de volumen - volume_features JSONB, -- Volume profiles, OBV, VWAP, etc. - - -- Features de precio - price_features JSONB, -- Price changes, returns, volatility, etc. - - -- Features de sentimiento (si disponible) - sentiment_features JSONB, -- Social sentiment, news sentiment, etc. - - -- Features on-chain (para crypto) - onchain_features JSONB, -- Network metrics, whale activity, etc. - - -- Features derivados - derived_features JSONB, -- Features calculados de otros features - - -- Metadata - feature_version VARCHAR(20), -- Versión del cálculo de features - calculation_timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), - calculation_duration_ms INTEGER, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - -- Constraints - CONSTRAINT unique_feature_set UNIQUE(symbol, timeframe, timestamp) -); - --- Índices -CREATE INDEX idx_feature_store_symbol ON ml.feature_store(symbol); -CREATE INDEX idx_feature_store_symbol_time ON ml.feature_store(symbol, timeframe, timestamp DESC); -CREATE INDEX idx_feature_store_timestamp ON ml.feature_store(timestamp DESC); -CREATE INDEX idx_feature_store_version ON ml.feature_store(feature_version); - --- Particionamiento por fecha (recomendado para alto volumen) --- Se puede implementar particionamiento mensual o trimestral - --- Comentarios -COMMENT ON TABLE ml.feature_store IS 'Pre-calculated features for ML model inference and training'; -COMMENT ON COLUMN ml.feature_store.symbol IS 'Trading symbol (e.g., BTCUSDT)'; -COMMENT ON COLUMN ml.feature_store.timeframe IS 'Timeframe for features (1m, 5m, 15m, 1h, 4h, 1d)'; -COMMENT ON COLUMN ml.feature_store.timestamp IS 'Timestamp of the candle/bar these features represent'; -COMMENT ON COLUMN ml.feature_store.feature_version IS 'Version of feature calculation logic'; - --- Ejemplos de features JSONB: -COMMENT ON COLUMN ml.feature_store.technical_features IS -'Example: { - "rsi_14": 65.42, - "rsi_9": 68.21, - "macd": 0.0234, - "macd_signal": 0.0189, - "macd_histogram": 0.0045, - "bb_upper": 45678.90, - "bb_middle": 45234.12, - "bb_lower": 44789.34, - "sma_20": 45123.45, - "ema_12": 45234.56, - "ema_26": 45012.34 -}'; - -COMMENT ON COLUMN ml.feature_store.volume_features IS -'Example: { - "volume": 1234567.89, - "volume_sma_20": 1123456.78, - "volume_ratio": 1.098, - "obv": 98765432.10, - "vwap": 45234.12, - "buy_volume": 678901.23, - "sell_volume": 555666.66 -}'; - -COMMENT ON COLUMN ml.feature_store.price_features IS -'Example: { - "open": 45100.00, - "high": 45500.00, - "low": 44900.00, - "close": 45234.12, - "price_change_1h": 0.0145, - "price_change_4h": 0.0234, - "price_change_24h": 0.0567, - "volatility_1h": 0.0089, - "atr_14": 234.56 -}'; - -COMMENT ON COLUMN ml.feature_store.sentiment_features IS -'Example: { - "social_sentiment": 0.72, - "news_sentiment": 0.65, - "fear_greed_index": 68, - "twitter_volume": 12345, - "reddit_mentions": 678 -}'; - -COMMENT ON COLUMN ml.feature_store.onchain_features IS -'Example: { - "active_addresses": 123456, - "transaction_volume": 98765.43, - "exchange_inflow": 1234.56, - "exchange_outflow": 2345.67, - "whale_transactions": 23, - "network_growth": 0.023 -}'; diff --git a/apps/database/ddl/schemas/trading/00-enums.sql b/apps/database/ddl/schemas/trading/00-enums.sql deleted file mode 100644 index d30b9eb..0000000 --- a/apps/database/ddl/schemas/trading/00-enums.sql +++ /dev/null @@ -1,78 +0,0 @@ --- ============================================================================ --- Schema: trading --- File: 00-enums.sql --- Description: Enumeraciones para el módulo de trading --- Dependencies: None --- ============================================================================ - --- Tipo de orden -CREATE TYPE trading.order_type AS ENUM ( - 'market', - 'limit', - 'stop', - 'stop_limit', - 'trailing_stop' -); - --- Estado de orden (CON 'partially_filled' que faltaba!) -CREATE TYPE trading.order_status AS ENUM ( - 'pending', - 'open', - 'partially_filled', -- AGREGADO - faltaba en análisis - 'filled', - 'cancelled', - 'rejected', - 'expired' -); - --- Lado de la orden -CREATE TYPE trading.order_side AS ENUM ( - 'buy', - 'sell' -); - --- Estado de posición -CREATE TYPE trading.position_status AS ENUM ( - 'open', - 'closed', - 'liquidated' -); - --- Tipo de señal (interfaz con ML) -CREATE TYPE trading.signal_type AS ENUM ( - 'entry_long', - 'entry_short', - 'exit_long', - 'exit_short', - 'hold' -); - --- Nivel de confianza -CREATE TYPE trading.confidence_level AS ENUM ( - 'low', - 'medium', - 'high', - 'very_high' -); - --- Timeframes soportados -CREATE TYPE trading.timeframe AS ENUM ( - '1m', '5m', '15m', '30m', - '1h', '4h', - '1d', '1w', '1M' -); - --- Tipo de bot -CREATE TYPE trading.bot_type AS ENUM ( - 'paper', -- Paper trading (simulación) - 'live', -- Trading real - 'backtest' -- Backtesting -); - --- Estado de bot -CREATE TYPE trading.bot_status AS ENUM ( - 'active', - 'paused', - 'stopped', - 'error' -); diff --git a/apps/database/ddl/schemas/trading/functions/01-calculate_position_pnl.sql b/apps/database/ddl/schemas/trading/functions/01-calculate_position_pnl.sql deleted file mode 100644 index 6059978..0000000 --- a/apps/database/ddl/schemas/trading/functions/01-calculate_position_pnl.sql +++ /dev/null @@ -1,96 +0,0 @@ --- ============================================================================ --- Schema: trading --- Function: calculate_position_pnl --- Description: Calcula el PnL (realized y unrealized) de una posición --- Parameters: --- p_position_id: ID de la posición --- p_current_price: Precio actual del mercado (para unrealized PnL) --- Returns: JSONB con realized_pnl, unrealized_pnl, pnl_percentage --- ============================================================================ - -CREATE OR REPLACE FUNCTION trading.calculate_position_pnl( - p_position_id UUID, - p_current_price DECIMAL(20,8) DEFAULT NULL -) -RETURNS JSONB -LANGUAGE plpgsql -AS $$ -DECLARE - v_position RECORD; - v_realized_pnl DECIMAL(20,8) := 0; - v_unrealized_pnl DECIMAL(20,8) := 0; - v_pnl_percentage DECIMAL(10,4) := 0; - v_total_commission DECIMAL(20,8) := 0; - v_current_value DECIMAL(20,8); -BEGIN - -- Obtener datos de la posición - SELECT - p.position_side, - p.status, - p.entry_price, - p.entry_quantity, - p.entry_value, - p.entry_commission, - p.exit_price, - p.exit_quantity, - p.exit_value, - p.exit_commission - INTO v_position - FROM trading.positions p - WHERE p.id = p_position_id; - - IF NOT FOUND THEN - RAISE EXCEPTION 'Position % not found', p_position_id; - END IF; - - -- Calcular comisiones totales - v_total_commission := COALESCE(v_position.entry_commission, 0) + COALESCE(v_position.exit_commission, 0); - - -- Calcular PnL según el estado - IF v_position.status = 'closed' OR v_position.status = 'liquidated' THEN - -- Posición cerrada: calcular realized PnL - IF v_position.position_side = 'buy' THEN - -- Long position: profit = (exit_price - entry_price) * quantity - commissions - v_realized_pnl := (v_position.exit_value - v_position.entry_value) - v_total_commission; - ELSE - -- Short position: profit = (entry_price - exit_price) * quantity - commissions - v_realized_pnl := (v_position.entry_value - v_position.exit_value) - v_total_commission; - END IF; - - -- Calcular porcentaje - IF v_position.entry_value > 0 THEN - v_pnl_percentage := (v_realized_pnl / v_position.entry_value) * 100; - END IF; - - ELSIF v_position.status = 'open' AND p_current_price IS NOT NULL THEN - -- Posición abierta: calcular unrealized PnL - v_current_value := p_current_price * v_position.entry_quantity; - - IF v_position.position_side = 'buy' THEN - -- Long position - v_unrealized_pnl := (v_current_value - v_position.entry_value) - v_position.entry_commission; - ELSE - -- Short position - v_unrealized_pnl := (v_position.entry_value - v_current_value) - v_position.entry_commission; - END IF; - - -- Calcular porcentaje - IF v_position.entry_value > 0 THEN - v_pnl_percentage := (v_unrealized_pnl / v_position.entry_value) * 100; - END IF; - END IF; - - -- Retornar resultado - RETURN jsonb_build_object( - 'position_id', p_position_id, - 'status', v_position.status, - 'realized_pnl', v_realized_pnl, - 'unrealized_pnl', v_unrealized_pnl, - 'pnl_percentage', v_pnl_percentage, - 'total_commission', v_total_commission - ); -END; -$$; - --- Comentarios -COMMENT ON FUNCTION trading.calculate_position_pnl IS 'Calcula el PnL realizado y no realizado de una posición'; diff --git a/apps/database/ddl/schemas/trading/functions/02-update_bot_stats.sql b/apps/database/ddl/schemas/trading/functions/02-update_bot_stats.sql deleted file mode 100644 index aede3e3..0000000 --- a/apps/database/ddl/schemas/trading/functions/02-update_bot_stats.sql +++ /dev/null @@ -1,88 +0,0 @@ --- ============================================================================ --- Schema: trading --- Function: update_bot_stats --- Description: Actualiza las estadísticas de un bot basándose en sus posiciones --- Parameters: --- p_bot_id: ID del bot --- Returns: JSONB con las estadísticas actualizadas --- ============================================================================ - -CREATE OR REPLACE FUNCTION trading.update_bot_stats( - p_bot_id UUID -) -RETURNS JSONB -LANGUAGE plpgsql -AS $$ -DECLARE - v_total_trades INTEGER := 0; - v_winning_trades INTEGER := 0; - v_losing_trades INTEGER := 0; - v_total_pnl DECIMAL(20,8) := 0; - v_win_rate DECIMAL(5,2) := 0; - v_current_capital DECIMAL(20,8); - v_initial_capital DECIMAL(20,8); -BEGIN - -- Obtener capital inicial - SELECT initial_capital INTO v_initial_capital - FROM trading.bots - WHERE id = p_bot_id; - - IF NOT FOUND THEN - RAISE EXCEPTION 'Bot % not found', p_bot_id; - END IF; - - -- Contar trades cerrados - SELECT - COUNT(*)::INTEGER, - COUNT(*) FILTER (WHERE realized_pnl > 0)::INTEGER, - COUNT(*) FILTER (WHERE realized_pnl < 0)::INTEGER, - COALESCE(SUM(realized_pnl), 0) - INTO - v_total_trades, - v_winning_trades, - v_losing_trades, - v_total_pnl - FROM trading.positions - WHERE bot_id = p_bot_id - AND status IN ('closed', 'liquidated'); - - -- Calcular win rate - IF v_total_trades > 0 THEN - v_win_rate := (v_winning_trades::DECIMAL / v_total_trades::DECIMAL) * 100; - END IF; - - -- Calcular capital actual - v_current_capital := v_initial_capital + v_total_pnl; - - -- Actualizar bot - UPDATE trading.bots - SET - total_trades = v_total_trades, - winning_trades = v_winning_trades, - total_profit_loss = v_total_pnl, - win_rate = v_win_rate, - current_capital = v_current_capital, - updated_at = NOW() - WHERE id = p_bot_id; - - -- Retornar estadísticas - RETURN jsonb_build_object( - 'bot_id', p_bot_id, - 'total_trades', v_total_trades, - 'winning_trades', v_winning_trades, - 'losing_trades', v_losing_trades, - 'win_rate', v_win_rate, - 'total_profit_loss', v_total_pnl, - 'initial_capital', v_initial_capital, - 'current_capital', v_current_capital, - 'roi_percentage', CASE - WHEN v_initial_capital > 0 - THEN ((v_current_capital - v_initial_capital) / v_initial_capital * 100) - ELSE 0 - END - ); -END; -$$; - --- Comentarios -COMMENT ON FUNCTION trading.update_bot_stats IS 'Actualiza las estadísticas agregadas de un trading bot'; diff --git a/apps/database/ddl/schemas/trading/functions/03-initialize_paper_balance.sql b/apps/database/ddl/schemas/trading/functions/03-initialize_paper_balance.sql deleted file mode 100644 index c350e33..0000000 --- a/apps/database/ddl/schemas/trading/functions/03-initialize_paper_balance.sql +++ /dev/null @@ -1,165 +0,0 @@ --- ============================================================================ --- TRADING SCHEMA - Funcion: initialize_paper_balance --- ============================================================================ --- Inicializa el balance de paper trading para un usuario --- ============================================================================ - -CREATE OR REPLACE FUNCTION trading.initialize_paper_balance( - p_user_id UUID, - p_initial_amount DECIMAL(20, 8) DEFAULT 10000.00, - p_asset VARCHAR(10) DEFAULT 'USDT' -) -RETURNS UUID AS $$ -DECLARE - v_balance_id UUID; -BEGIN - INSERT INTO trading.paper_balances ( - user_id, - asset, - total, - available, - locked, - initial_balance - ) - VALUES ( - p_user_id, - p_asset, - p_initial_amount, - p_initial_amount, - 0, - p_initial_amount - ) - ON CONFLICT (user_id, asset) DO NOTHING - RETURNING id INTO v_balance_id; - - -- Si ya existia, obtener el ID - IF v_balance_id IS NULL THEN - SELECT id INTO v_balance_id - FROM trading.paper_balances - WHERE user_id = p_user_id AND asset = p_asset; - END IF; - - RETURN v_balance_id; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION trading.initialize_paper_balance IS 'Inicializa balance de paper trading para un usuario (default $10,000 USDT)'; - --- ============================================================================ --- Funcion: reset_paper_balance --- ============================================================================ --- Resetea el balance de paper trading a su valor inicial --- ============================================================================ - -CREATE OR REPLACE FUNCTION trading.reset_paper_balance( - p_user_id UUID, - p_asset VARCHAR(10) DEFAULT 'USDT', - p_new_amount DECIMAL(20, 8) DEFAULT NULL -) -RETURNS BOOLEAN AS $$ -DECLARE - v_initial DECIMAL(20, 8); -BEGIN - -- Obtener balance inicial original o usar el nuevo monto - IF p_new_amount IS NOT NULL THEN - v_initial := p_new_amount; - ELSE - SELECT initial_balance INTO v_initial - FROM trading.paper_balances - WHERE user_id = p_user_id AND asset = p_asset; - END IF; - - -- Resetear balance - UPDATE trading.paper_balances - SET - total = v_initial, - available = v_initial, - locked = 0, - initial_balance = v_initial, - total_deposits = 0, - total_withdrawals = 0, - total_pnl = 0, - last_reset_at = NOW(), - reset_count = reset_count + 1, - updated_at = NOW() - WHERE user_id = p_user_id AND asset = p_asset; - - RETURN FOUND; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION trading.reset_paper_balance IS 'Resetea el balance de paper trading al valor inicial'; - --- ============================================================================ --- Funcion: update_paper_balance --- ============================================================================ --- Actualiza el balance de paper trading (lock/unlock/pnl) --- ============================================================================ - -CREATE OR REPLACE FUNCTION trading.update_paper_balance( - p_user_id UUID, - p_asset VARCHAR(10), - p_amount DECIMAL(20, 8), - p_operation VARCHAR(20) -- 'lock', 'unlock', 'pnl', 'deposit', 'withdrawal' -) -RETURNS BOOLEAN AS $$ -BEGIN - CASE p_operation - WHEN 'lock' THEN - UPDATE trading.paper_balances - SET - available = available - p_amount, - locked = locked + p_amount, - updated_at = NOW() - WHERE user_id = p_user_id - AND asset = p_asset - AND available >= p_amount; - - WHEN 'unlock' THEN - UPDATE trading.paper_balances - SET - available = available + p_amount, - locked = locked - p_amount, - updated_at = NOW() - WHERE user_id = p_user_id - AND asset = p_asset - AND locked >= p_amount; - - WHEN 'pnl' THEN - UPDATE trading.paper_balances - SET - total = total + p_amount, - available = available + p_amount, - total_pnl = total_pnl + p_amount, - updated_at = NOW() - WHERE user_id = p_user_id AND asset = p_asset; - - WHEN 'deposit' THEN - UPDATE trading.paper_balances - SET - total = total + p_amount, - available = available + p_amount, - total_deposits = total_deposits + p_amount, - updated_at = NOW() - WHERE user_id = p_user_id AND asset = p_asset; - - WHEN 'withdrawal' THEN - UPDATE trading.paper_balances - SET - total = total - p_amount, - available = available - p_amount, - total_withdrawals = total_withdrawals + p_amount, - updated_at = NOW() - WHERE user_id = p_user_id - AND asset = p_asset - AND available >= p_amount; - - ELSE - RETURN FALSE; - END CASE; - - RETURN FOUND; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION trading.update_paper_balance IS 'Actualiza balance de paper trading (lock/unlock/pnl/deposit/withdrawal)'; diff --git a/apps/database/ddl/schemas/trading/functions/04-create_default_watchlist.sql b/apps/database/ddl/schemas/trading/functions/04-create_default_watchlist.sql deleted file mode 100644 index bca56b8..0000000 --- a/apps/database/ddl/schemas/trading/functions/04-create_default_watchlist.sql +++ /dev/null @@ -1,80 +0,0 @@ --- ============================================================================ --- TRADING SCHEMA - Funcion: create_default_watchlist --- ============================================================================ --- Crea watchlist default y balance inicial para nuevos usuarios --- Se ejecuta como trigger en auth.users --- ============================================================================ - -CREATE OR REPLACE FUNCTION trading.create_user_trading_defaults() -RETURNS TRIGGER AS $$ -DECLARE - v_watchlist_id UUID; - v_btc_id UUID; - v_eth_id UUID; - v_bnb_id UUID; -BEGIN - -- 1. Crear watchlist default "My Favorites" - INSERT INTO trading.watchlists ( - user_id, - name, - description, - is_default, - display_order - ) - VALUES ( - NEW.id, - 'My Favorites', - 'Default watchlist with popular trading pairs', - true, - 0 - ) - RETURNING id INTO v_watchlist_id; - - -- 2. Agregar simbolos populares si existen en la tabla symbols - SELECT id INTO v_btc_id FROM trading.symbols WHERE symbol = 'BTCUSDT' LIMIT 1; - SELECT id INTO v_eth_id FROM trading.symbols WHERE symbol = 'ETHUSDT' LIMIT 1; - SELECT id INTO v_bnb_id FROM trading.symbols WHERE symbol = 'BNBUSDT' LIMIT 1; - - IF v_btc_id IS NOT NULL THEN - INSERT INTO trading.watchlist_items (watchlist_id, symbol_id, display_order) - VALUES (v_watchlist_id, v_btc_id, 0) - ON CONFLICT DO NOTHING; - END IF; - - IF v_eth_id IS NOT NULL THEN - INSERT INTO trading.watchlist_items (watchlist_id, symbol_id, display_order) - VALUES (v_watchlist_id, v_eth_id, 1) - ON CONFLICT DO NOTHING; - END IF; - - IF v_bnb_id IS NOT NULL THEN - INSERT INTO trading.watchlist_items (watchlist_id, symbol_id, display_order) - VALUES (v_watchlist_id, v_bnb_id, 2) - ON CONFLICT DO NOTHING; - END IF; - - -- 3. Inicializar balance de paper trading ($10,000 USDT) - PERFORM trading.initialize_paper_balance(NEW.id, 10000.00, 'USDT'); - - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -COMMENT ON FUNCTION trading.create_user_trading_defaults IS 'Trigger function: crea watchlist default y balance inicial para nuevos usuarios'; - --- ============================================================================ --- Trigger en auth.users --- ============================================================================ --- Nota: Este trigger debe crearse despues de que exista la tabla auth.users --- Se recomienda ejecutarlo en un script de post-instalacion o manualmente --- ============================================================================ - --- DROP TRIGGER IF EXISTS tr_create_user_trading_defaults ON auth.users; - --- CREATE TRIGGER tr_create_user_trading_defaults --- AFTER INSERT ON auth.users --- FOR EACH ROW --- EXECUTE FUNCTION trading.create_user_trading_defaults(); - --- COMMENT ON TRIGGER tr_create_user_trading_defaults ON auth.users IS --- 'Crea watchlist default y balance de paper trading para nuevos usuarios'; diff --git a/apps/database/ddl/schemas/trading/tables/01-symbols.sql b/apps/database/ddl/schemas/trading/tables/01-symbols.sql deleted file mode 100644 index 7858d70..0000000 --- a/apps/database/ddl/schemas/trading/tables/01-symbols.sql +++ /dev/null @@ -1,49 +0,0 @@ --- ============================================================================ --- Schema: trading --- Table: symbols --- Description: Catálogo de símbolos/instrumentos financieros operables --- Dependencies: None --- ============================================================================ - -CREATE TABLE trading.symbols ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Identificación - symbol VARCHAR(20) NOT NULL UNIQUE, -- BTC/USDT, EUR/USD - name VARCHAR(100) NOT NULL, - base_asset VARCHAR(10) NOT NULL, -- BTC, EUR - quote_asset VARCHAR(10) NOT NULL, -- USDT, USD - - -- Tipo - asset_class VARCHAR(50) NOT NULL, -- crypto, forex, stocks - exchange VARCHAR(50), -- binance, coinbase - - -- Precisión - price_precision INTEGER NOT NULL DEFAULT 8, - quantity_precision INTEGER NOT NULL DEFAULT 8, - - -- Límites - min_quantity DECIMAL(20,8), - max_quantity DECIMAL(20,8), - min_notional DECIMAL(20,8), - - -- Estado - is_active BOOLEAN DEFAULT true, - is_tradeable BOOLEAN DEFAULT true, - - -- Timestamps - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Índices -CREATE INDEX idx_symbols_symbol ON trading.symbols(symbol); -CREATE INDEX idx_symbols_active ON trading.symbols(is_active) WHERE is_active = true; -CREATE INDEX idx_symbols_asset_class ON trading.symbols(asset_class); -CREATE INDEX idx_symbols_exchange ON trading.symbols(exchange); - --- Comentarios -COMMENT ON TABLE trading.symbols IS 'Catálogo de símbolos/instrumentos financieros operables'; -COMMENT ON COLUMN trading.symbols.symbol IS 'Símbolo del instrumento (e.g., BTC/USDT)'; -COMMENT ON COLUMN trading.symbols.asset_class IS 'Clase de activo: crypto, forex, stocks, commodities'; -COMMENT ON COLUMN trading.symbols.is_tradeable IS 'Indica si el símbolo está disponible para trading'; diff --git a/apps/database/ddl/schemas/trading/tables/02-watchlists.sql b/apps/database/ddl/schemas/trading/tables/02-watchlists.sql deleted file mode 100644 index bd53a13..0000000 --- a/apps/database/ddl/schemas/trading/tables/02-watchlists.sql +++ /dev/null @@ -1,40 +0,0 @@ --- ============================================================================ --- Schema: trading --- Table: watchlists --- Description: Listas de seguimiento personalizadas de usuarios --- Dependencies: auth.users --- ============================================================================ - -CREATE TABLE trading.watchlists ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Propietario - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - - -- Configuración - name VARCHAR(100) NOT NULL, - description TEXT, - - -- Ordenamiento - display_order INTEGER DEFAULT 0, - - -- Estado - is_default BOOLEAN DEFAULT false, - is_public BOOLEAN DEFAULT false, - - -- Timestamps - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - -- Constraint: nombre único por usuario - CONSTRAINT uq_watchlists_user_name UNIQUE (user_id, name) -); - --- Índices -CREATE INDEX idx_watchlists_user ON trading.watchlists(user_id); -CREATE INDEX idx_watchlists_user_default ON trading.watchlists(user_id, is_default) WHERE is_default = true; - --- Comentarios -COMMENT ON TABLE trading.watchlists IS 'Listas de seguimiento personalizadas de símbolos'; -COMMENT ON COLUMN trading.watchlists.is_default IS 'Indica si es la watchlist por defecto del usuario'; -COMMENT ON COLUMN trading.watchlists.is_public IS 'Indica si la watchlist es visible públicamente'; diff --git a/apps/database/ddl/schemas/trading/tables/03-watchlist_items.sql b/apps/database/ddl/schemas/trading/tables/03-watchlist_items.sql deleted file mode 100644 index e20dc6d..0000000 --- a/apps/database/ddl/schemas/trading/tables/03-watchlist_items.sql +++ /dev/null @@ -1,38 +0,0 @@ --- ============================================================================ --- Schema: trading --- Table: watchlist_items --- Description: Items individuales dentro de watchlists --- Dependencies: trading.watchlists, trading.symbols --- ============================================================================ - -CREATE TABLE trading.watchlist_items ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Referencias - watchlist_id UUID NOT NULL REFERENCES trading.watchlists(id) ON DELETE CASCADE, - symbol_id UUID NOT NULL REFERENCES trading.symbols(id) ON DELETE CASCADE, - - -- Configuración personalizada - notes TEXT, - alert_price_high DECIMAL(20,8), - alert_price_low DECIMAL(20,8), - - -- Ordenamiento - display_order INTEGER DEFAULT 0, - - -- Timestamps - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - -- Constraint: un símbolo solo una vez por watchlist - CONSTRAINT uq_watchlist_items_watchlist_symbol UNIQUE (watchlist_id, symbol_id) -); - --- Índices -CREATE INDEX idx_watchlist_items_watchlist ON trading.watchlist_items(watchlist_id); -CREATE INDEX idx_watchlist_items_symbol ON trading.watchlist_items(symbol_id); - --- Comentarios -COMMENT ON TABLE trading.watchlist_items IS 'Símbolos individuales dentro de una watchlist'; -COMMENT ON COLUMN trading.watchlist_items.alert_price_high IS 'Precio superior para alertas'; -COMMENT ON COLUMN trading.watchlist_items.alert_price_low IS 'Precio inferior para alertas'; diff --git a/apps/database/ddl/schemas/trading/tables/04-bots.sql b/apps/database/ddl/schemas/trading/tables/04-bots.sql deleted file mode 100644 index 3eef4fc..0000000 --- a/apps/database/ddl/schemas/trading/tables/04-bots.sql +++ /dev/null @@ -1,64 +0,0 @@ --- ============================================================================ --- Schema: trading --- Table: bots --- Description: Trading bots configurados por usuarios --- Dependencies: auth.users, trading.bot_type, trading.bot_status, trading.timeframe --- ============================================================================ - -CREATE TABLE trading.bots ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Propietario - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - - -- Configuración básica - name VARCHAR(100) NOT NULL, - bot_type trading.bot_type NOT NULL DEFAULT 'paper', - status trading.bot_status NOT NULL DEFAULT 'paused', - - -- Símbolos a operar - symbols VARCHAR(20)[] NOT NULL, - timeframe trading.timeframe NOT NULL DEFAULT '1h', - - -- Capital - initial_capital DECIMAL(20,8) NOT NULL, - current_capital DECIMAL(20,8) NOT NULL, - - -- Risk management - max_position_size_pct DECIMAL(5,2) DEFAULT 10.00, -- % del capital - max_daily_loss_pct DECIMAL(5,2) DEFAULT 5.00, - max_drawdown_pct DECIMAL(5,2) DEFAULT 20.00, - - -- Estrategia (referencia a ML signal provider) - strategy_type VARCHAR(50), -- 'atlas', 'orion', 'nova', 'custom' - strategy_config JSONB DEFAULT '{}', - - -- Estadísticas - total_trades INTEGER DEFAULT 0, - winning_trades INTEGER DEFAULT 0, - total_profit_loss DECIMAL(20,8) DEFAULT 0, - win_rate DECIMAL(5,2) DEFAULT 0, - - -- Metadata - started_at TIMESTAMPTZ, - stopped_at TIMESTAMPTZ, - last_trade_at TIMESTAMPTZ, - - -- Timestamps - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Índices -CREATE INDEX idx_bots_user ON trading.bots(user_id); -CREATE INDEX idx_bots_status ON trading.bots(status); -CREATE INDEX idx_bots_type ON trading.bots(bot_type); -CREATE INDEX idx_bots_user_status ON trading.bots(user_id, status); - --- Comentarios -COMMENT ON TABLE trading.bots IS 'Trading bots automatizados configurados por usuarios'; -COMMENT ON COLUMN trading.bots.bot_type IS 'Tipo: paper (simulación), live (real), backtest'; -COMMENT ON COLUMN trading.bots.strategy_type IS 'Estrategia ML: atlas, orion, nova, custom'; -COMMENT ON COLUMN trading.bots.max_position_size_pct IS 'Máximo % del capital por posición'; -COMMENT ON COLUMN trading.bots.max_daily_loss_pct IS 'Máxima pérdida diaria permitida (%)'; -COMMENT ON COLUMN trading.bots.max_drawdown_pct IS 'Máximo drawdown permitido (%)'; diff --git a/apps/database/ddl/schemas/trading/tables/05-orders.sql b/apps/database/ddl/schemas/trading/tables/05-orders.sql deleted file mode 100644 index c937b21..0000000 --- a/apps/database/ddl/schemas/trading/tables/05-orders.sql +++ /dev/null @@ -1,67 +0,0 @@ --- ============================================================================ --- Schema: trading --- Table: orders --- Description: Órdenes de trading (pendientes, ejecutadas, canceladas) --- Dependencies: auth.users, trading.bots, trading.symbols --- ============================================================================ - -CREATE TABLE trading.orders ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Referencias - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - bot_id UUID REFERENCES trading.bots(id) ON DELETE SET NULL, - symbol_id UUID NOT NULL REFERENCES trading.symbols(id), - - -- Identificador externo (de exchange) - external_order_id VARCHAR(100), - - -- Tipo y lado - order_type trading.order_type NOT NULL, - order_side trading.order_side NOT NULL, - status trading.order_status NOT NULL DEFAULT 'pending', - - -- Precios - price DECIMAL(20,8), -- Precio límite (NULL para market orders) - stop_price DECIMAL(20,8), -- Precio stop - average_fill_price DECIMAL(20,8), -- Precio promedio de ejecución - - -- Cantidades - quantity DECIMAL(20,8) NOT NULL, - filled_quantity DECIMAL(20,8) DEFAULT 0, - remaining_quantity DECIMAL(20,8) NOT NULL, - - -- Costos - commission DECIMAL(20,8) DEFAULT 0, - commission_asset VARCHAR(10), - - -- Time in force - time_in_force VARCHAR(20) DEFAULT 'GTC', -- GTC, IOC, FOK - - -- Validez - expires_at TIMESTAMPTZ, - - -- Metadata - metadata JSONB DEFAULT '{}', - error_message TEXT, - - -- Timestamps - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - filled_at TIMESTAMPTZ, - cancelled_at TIMESTAMPTZ -); - --- Índices -CREATE INDEX idx_orders_user ON trading.orders(user_id); -CREATE INDEX idx_orders_bot ON trading.orders(bot_id); -CREATE INDEX idx_orders_symbol ON trading.orders(symbol_id); -CREATE INDEX idx_orders_status ON trading.orders(status); -CREATE INDEX idx_orders_created ON trading.orders(created_at DESC); -CREATE INDEX idx_orders_external ON trading.orders(external_order_id); - --- Comentarios -COMMENT ON TABLE trading.orders IS 'Órdenes de trading (todas las órdenes del sistema)'; -COMMENT ON COLUMN trading.orders.external_order_id IS 'ID de la orden en el exchange externo'; -COMMENT ON COLUMN trading.orders.time_in_force IS 'GTC (Good Till Cancel), IOC (Immediate or Cancel), FOK (Fill or Kill)'; -COMMENT ON COLUMN trading.orders.average_fill_price IS 'Precio promedio de ejecución para órdenes parcialmente completadas'; diff --git a/apps/database/ddl/schemas/trading/tables/06-positions.sql b/apps/database/ddl/schemas/trading/tables/06-positions.sql deleted file mode 100644 index 92da537..0000000 --- a/apps/database/ddl/schemas/trading/tables/06-positions.sql +++ /dev/null @@ -1,70 +0,0 @@ --- ============================================================================ --- Schema: trading --- Table: positions --- Description: Posiciones abiertas y cerradas de trading --- Dependencies: auth.users, trading.bots, trading.symbols --- ============================================================================ - -CREATE TABLE trading.positions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Referencias - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - bot_id UUID REFERENCES trading.bots(id) ON DELETE SET NULL, - symbol_id UUID NOT NULL REFERENCES trading.symbols(id), - - -- Tipo - position_side trading.order_side NOT NULL, -- buy (long), sell (short) - status trading.position_status NOT NULL DEFAULT 'open', - - -- Entrada - entry_price DECIMAL(20,8) NOT NULL, - entry_quantity DECIMAL(20,8) NOT NULL, - entry_value DECIMAL(20,8) NOT NULL, - entry_commission DECIMAL(20,8) DEFAULT 0, - - -- Posiciones parciales (para cierres parciales y promedios) - current_quantity DECIMAL(20,8) NOT NULL, -- Cantidad actual (puede diferir de entry_quantity) - average_entry_price DECIMAL(20,8) NOT NULL, -- Precio promedio de entrada (para DCA) - - -- Salida - exit_price DECIMAL(20,8), - exit_quantity DECIMAL(20,8), - exit_value DECIMAL(20,8), - exit_commission DECIMAL(20,8) DEFAULT 0, - - -- PnL - realized_pnl DECIMAL(20,8) DEFAULT 0, - unrealized_pnl DECIMAL(20,8) DEFAULT 0, - pnl_percentage DECIMAL(10,4) DEFAULT 0, - - -- Stop loss y take profit - stop_loss_price DECIMAL(20,8), - take_profit_price DECIMAL(20,8), - - -- Metadata - metadata JSONB DEFAULT '{}', - close_reason VARCHAR(50), -- 'take_profit', 'stop_loss', 'manual', 'liquidation' - - -- Timestamps - opened_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - closed_at TIMESTAMPTZ, - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Índices -CREATE INDEX idx_positions_user ON trading.positions(user_id); -CREATE INDEX idx_positions_bot ON trading.positions(bot_id); -CREATE INDEX idx_positions_symbol ON trading.positions(symbol_id); -CREATE INDEX idx_positions_status ON trading.positions(status); -CREATE INDEX idx_positions_opened ON trading.positions(opened_at DESC); -CREATE INDEX idx_positions_user_status ON trading.positions(user_id, status); - --- Comentarios -COMMENT ON TABLE trading.positions IS 'Posiciones de trading abiertas y cerradas'; -COMMENT ON COLUMN trading.positions.position_side IS 'buy = posición larga, sell = posición corta'; -COMMENT ON COLUMN trading.positions.realized_pnl IS 'Ganancia/pérdida realizada (posición cerrada)'; -COMMENT ON COLUMN trading.positions.unrealized_pnl IS 'Ganancia/pérdida no realizada (posición abierta)'; -COMMENT ON COLUMN trading.positions.close_reason IS 'Razón del cierre: take_profit, stop_loss, manual, liquidation'; -COMMENT ON COLUMN trading.positions.current_quantity IS 'Cantidad actual de la posición (puede diferir de entry_quantity tras cierres parciales)'; -COMMENT ON COLUMN trading.positions.average_entry_price IS 'Precio promedio de entrada (calculado con DCA o adiciones)'; diff --git a/apps/database/ddl/schemas/trading/tables/07-trades.sql b/apps/database/ddl/schemas/trading/tables/07-trades.sql deleted file mode 100644 index 3094231..0000000 --- a/apps/database/ddl/schemas/trading/tables/07-trades.sql +++ /dev/null @@ -1,49 +0,0 @@ --- ============================================================================ --- Schema: trading --- Table: trades --- Description: Historial detallado de trades ejecutados --- Dependencies: trading.orders, trading.positions --- ============================================================================ - -CREATE TABLE trading.trades ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Referencias - order_id UUID NOT NULL REFERENCES trading.orders(id) ON DELETE CASCADE, - position_id UUID REFERENCES trading.positions(id) ON DELETE SET NULL, - - -- Identificador externo (de exchange) - external_trade_id VARCHAR(100), - - -- Detalles del trade - symbol VARCHAR(20) NOT NULL, - side trading.order_side NOT NULL, - price DECIMAL(20,8) NOT NULL, - quantity DECIMAL(20,8) NOT NULL, - quote_quantity DECIMAL(20,8) NOT NULL, -- price * quantity - - -- Comisión - commission DECIMAL(20,8) DEFAULT 0, - commission_asset VARCHAR(10), - - -- Metadata - is_maker BOOLEAN DEFAULT false, - metadata JSONB DEFAULT '{}', - - -- Timestamps - executed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Índices -CREATE INDEX idx_trades_order ON trading.trades(order_id); -CREATE INDEX idx_trades_position ON trading.trades(position_id); -CREATE INDEX idx_trades_symbol ON trading.trades(symbol); -CREATE INDEX idx_trades_executed ON trading.trades(executed_at DESC); -CREATE INDEX idx_trades_external ON trading.trades(external_trade_id); - --- Comentarios -COMMENT ON TABLE trading.trades IS 'Historial detallado de trades ejecutados (fills individuales)'; -COMMENT ON COLUMN trading.trades.external_trade_id IS 'ID del trade en el exchange externo'; -COMMENT ON COLUMN trading.trades.quote_quantity IS 'Valor total del trade (price * quantity)'; -COMMENT ON COLUMN trading.trades.is_maker IS 'true si el trade fue maker, false si fue taker'; diff --git a/apps/database/ddl/schemas/trading/tables/08-signals.sql b/apps/database/ddl/schemas/trading/tables/08-signals.sql deleted file mode 100644 index c8065c6..0000000 --- a/apps/database/ddl/schemas/trading/tables/08-signals.sql +++ /dev/null @@ -1,68 +0,0 @@ --- ============================================================================ --- Schema: trading --- Table: signals --- Description: INTERFAZ con ML - Señales de trading generadas por modelos ML --- Dependencies: trading.signal_type, trading.confidence_level, trading.timeframe --- --- IMPORTANTE: Esta tabla es la INTERFAZ entre Trading y ML Signals --- Resuelve la dependencia circular permitiendo que ambos módulos --- trabajen independientemente. ML escribe señales aquí, Trading las consume. --- ============================================================================ - -CREATE TABLE trading.signals ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Origen de la señal - source VARCHAR(50) NOT NULL, -- 'ml_atlas', 'ml_orion', 'ml_nova', 'manual' - model_version VARCHAR(50), - - -- Target - symbol VARCHAR(20) NOT NULL, - timeframe trading.timeframe NOT NULL, - - -- Señal - signal_type trading.signal_type NOT NULL, - confidence trading.confidence_level NOT NULL, - confidence_score DECIMAL(5,4), -- 0.0000 to 1.0000 - - -- Precios objetivo - entry_price DECIMAL(20,8), - target_price DECIMAL(20,8), - stop_loss DECIMAL(20,8), - - -- Predicciones - predicted_delta_high DECIMAL(10,4), -- % esperado de subida - predicted_delta_low DECIMAL(10,4), -- % esperado de bajada - - -- Resultado (se actualiza después) - actual_outcome VARCHAR(20), -- 'hit_target', 'hit_stop', 'expired', 'cancelled' - actual_delta DECIMAL(10,4), - outcome_at TIMESTAMPTZ, - - -- Validez - valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW(), - valid_until TIMESTAMPTZ NOT NULL, - is_active BOOLEAN DEFAULT true, - - -- Metadata - metadata JSONB DEFAULT '{}', - - -- Timestamps - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Índices -CREATE INDEX idx_signals_symbol ON trading.signals(symbol); -CREATE INDEX idx_signals_active ON trading.signals(is_active, valid_until) WHERE is_active = true; -CREATE INDEX idx_signals_source ON trading.signals(source); -CREATE INDEX idx_signals_created ON trading.signals(created_at DESC); -CREATE INDEX idx_signals_symbol_timeframe ON trading.signals(symbol, timeframe); -CREATE INDEX idx_signals_outcome ON trading.signals(actual_outcome); - --- Comentarios -COMMENT ON TABLE trading.signals IS 'INTERFAZ: Señales de trading generadas por modelos ML o manualmente'; -COMMENT ON COLUMN trading.signals.source IS 'Origen: ml_atlas, ml_orion, ml_nova, manual'; -COMMENT ON COLUMN trading.signals.confidence_score IS 'Score numérico de confianza entre 0 y 1'; -COMMENT ON COLUMN trading.signals.predicted_delta_high IS 'Porcentaje esperado de subida desde entry_price'; -COMMENT ON COLUMN trading.signals.predicted_delta_low IS 'Porcentaje esperado de bajada desde entry_price'; -COMMENT ON COLUMN trading.signals.actual_outcome IS 'Resultado real: hit_target, hit_stop, expired, cancelled'; diff --git a/apps/database/ddl/schemas/trading/tables/09-trading_metrics.sql b/apps/database/ddl/schemas/trading/tables/09-trading_metrics.sql deleted file mode 100644 index 3c83eab..0000000 --- a/apps/database/ddl/schemas/trading/tables/09-trading_metrics.sql +++ /dev/null @@ -1,67 +0,0 @@ --- ============================================================================ --- Schema: trading --- Table: trading_metrics --- Description: Métricas diarias de performance por bot --- Dependencies: trading.bots --- ============================================================================ - -CREATE TABLE trading.trading_metrics ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Referencias - bot_id UUID NOT NULL REFERENCES trading.bots(id) ON DELETE CASCADE, - - -- Período - metric_date DATE NOT NULL, - - -- Capital - starting_capital DECIMAL(20,8) NOT NULL, - ending_capital DECIMAL(20,8) NOT NULL, - peak_capital DECIMAL(20,8) NOT NULL, - - -- Trading - total_trades INTEGER DEFAULT 0, - winning_trades INTEGER DEFAULT 0, - losing_trades INTEGER DEFAULT 0, - - -- PnL - gross_profit DECIMAL(20,8) DEFAULT 0, - gross_loss DECIMAL(20,8) DEFAULT 0, - net_profit DECIMAL(20,8) DEFAULT 0, - - -- Ratios - win_rate DECIMAL(5,2) DEFAULT 0, - profit_factor DECIMAL(10,4) DEFAULT 0, -- gross_profit / gross_loss - sharpe_ratio DECIMAL(10,4), - max_drawdown DECIMAL(10,4) DEFAULT 0, - - -- Trades - avg_win DECIMAL(20,8) DEFAULT 0, - avg_loss DECIMAL(20,8) DEFAULT 0, - largest_win DECIMAL(20,8) DEFAULT 0, - largest_loss DECIMAL(20,8) DEFAULT 0, - - -- Tiempos promedio - avg_trade_duration_minutes INTEGER, - - -- Metadata - metadata JSONB DEFAULT '{}', - - -- Timestamps - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - -- Constraint: una métrica por bot por día - CONSTRAINT uq_trading_metrics_bot_date UNIQUE (bot_id, metric_date) -); - --- Índices -CREATE INDEX idx_trading_metrics_bot ON trading.trading_metrics(bot_id); -CREATE INDEX idx_trading_metrics_date ON trading.trading_metrics(metric_date DESC); -CREATE INDEX idx_trading_metrics_bot_date ON trading.trading_metrics(bot_id, metric_date DESC); - --- Comentarios -COMMENT ON TABLE trading.trading_metrics IS 'Métricas diarias de performance por bot'; -COMMENT ON COLUMN trading.trading_metrics.profit_factor IS 'Ratio gross_profit / gross_loss (>1 es rentable)'; -COMMENT ON COLUMN trading.trading_metrics.sharpe_ratio IS 'Ratio de Sharpe ajustado por riesgo'; -COMMENT ON COLUMN trading.trading_metrics.max_drawdown IS 'Máximo drawdown del día (%)'; diff --git a/apps/database/ddl/schemas/trading/tables/10-paper_balances.sql b/apps/database/ddl/schemas/trading/tables/10-paper_balances.sql deleted file mode 100644 index e282203..0000000 --- a/apps/database/ddl/schemas/trading/tables/10-paper_balances.sql +++ /dev/null @@ -1,51 +0,0 @@ --- ============================================================================ --- TRADING SCHEMA - Tabla: paper_balances --- ============================================================================ --- Balances virtuales para paper trading --- Permite a usuarios practicar sin riesgo real --- ============================================================================ - -CREATE TABLE IF NOT EXISTS trading.paper_balances ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - - -- Usuario - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - - -- Asset y balance - asset VARCHAR(10) NOT NULL DEFAULT 'USDT', - total DECIMAL(20, 8) NOT NULL DEFAULT 10000.00, - available DECIMAL(20, 8) NOT NULL DEFAULT 10000.00, - locked DECIMAL(20, 8) NOT NULL DEFAULT 0, - - -- Tracking - initial_balance DECIMAL(20, 8) NOT NULL DEFAULT 10000.00, - total_deposits DECIMAL(20, 8) NOT NULL DEFAULT 0, - total_withdrawals DECIMAL(20, 8) NOT NULL DEFAULT 0, - total_pnl DECIMAL(20, 8) NOT NULL DEFAULT 0, - - -- Reset tracking - last_reset_at TIMESTAMPTZ, - reset_count INTEGER NOT NULL DEFAULT 0, - - -- Timestamps - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - -- Constraints - CONSTRAINT uq_paper_balances_user_asset UNIQUE(user_id, asset), - CONSTRAINT chk_balance_consistency CHECK (total = available + locked), - CONSTRAINT chk_balance_non_negative CHECK (total >= 0 AND available >= 0 AND locked >= 0), - CONSTRAINT chk_initial_positive CHECK (initial_balance > 0) -); - --- Indices -CREATE INDEX idx_paper_balances_user ON trading.paper_balances(user_id); -CREATE INDEX idx_paper_balances_asset ON trading.paper_balances(asset); - --- Comentarios -COMMENT ON TABLE trading.paper_balances IS 'Balances virtuales para paper trading - cada usuario tiene balance por asset'; -COMMENT ON COLUMN trading.paper_balances.total IS 'Balance total = available + locked'; -COMMENT ON COLUMN trading.paper_balances.available IS 'Balance disponible para nuevas ordenes'; -COMMENT ON COLUMN trading.paper_balances.locked IS 'Balance bloqueado en ordenes abiertas'; -COMMENT ON COLUMN trading.paper_balances.initial_balance IS 'Balance inicial (default $10,000 USDT)'; -COMMENT ON COLUMN trading.paper_balances.reset_count IS 'Numero de veces que el usuario ha reseteado su balance'; diff --git a/apps/database/schemas/00_init_schemas.sql b/apps/database/schemas/00_init_schemas.sql deleted file mode 100644 index eac578d..0000000 --- a/apps/database/schemas/00_init_schemas.sql +++ /dev/null @@ -1,123 +0,0 @@ --- ============================================================================ --- OrbiQuant IA - Inicialización de Esquemas --- ============================================================================ --- Archivo: 00_init_schemas.sql --- Descripción: Creación de esquemas y extensiones base --- Fecha: 2025-12-05 --- ============================================================================ - --- Extensiones requeridas -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- Generación de UUIDs -CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- Funciones criptográficas -CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- Búsqueda de texto similar - --- ============================================================================ --- ESQUEMAS --- ============================================================================ - --- Esquema público (usuarios y configuración global) --- Ya existe por defecto, solo documentamos -COMMENT ON SCHEMA public IS 'Usuarios, perfiles, configuración global y autenticación'; - --- Esquema de educación -CREATE SCHEMA IF NOT EXISTS education; -COMMENT ON SCHEMA education IS 'Cursos, lecciones, inscripciones y contenido educativo'; - --- Esquema de trading -CREATE SCHEMA IF NOT EXISTS trading; -COMMENT ON SCHEMA trading IS 'Bots, señales, estrategias y operaciones de trading'; - --- Esquema de inversión -CREATE SCHEMA IF NOT EXISTS investment; -COMMENT ON SCHEMA investment IS 'Cuentas de inversión, productos y gestión de portafolios'; - --- Esquema financiero -CREATE SCHEMA IF NOT EXISTS financial; -COMMENT ON SCHEMA financial IS 'Pagos, suscripciones, wallets y transacciones'; - --- Esquema de machine learning -CREATE SCHEMA IF NOT EXISTS ml; -COMMENT ON SCHEMA ml IS 'Modelos ML, predicciones, features y métricas'; - --- Esquema de auditoría -CREATE SCHEMA IF NOT EXISTS audit; -COMMENT ON SCHEMA audit IS 'Logs de auditoría, eventos del sistema y seguridad'; - --- ============================================================================ --- TIPOS ENUMERADOS GLOBALES --- ============================================================================ - --- Estados comunes -CREATE TYPE status_enum AS ENUM ('active', 'inactive', 'pending', 'suspended', 'deleted'); - --- Roles del sistema -CREATE TYPE user_role_enum AS ENUM ( - 'investor', -- Cliente final pasivo - 'trader_pro', -- Usuario activo con herramientas - 'student', -- Alumno de cursos - 'admin', -- Administrador - 'risk_officer', -- Oficial de riesgos - 'support' -- Soporte al cliente -); - --- Perfiles de riesgo -CREATE TYPE risk_profile_enum AS ENUM ('conservative', 'moderate', 'aggressive'); - --- Direcciones de trading -CREATE TYPE trade_direction_enum AS ENUM ('long', 'short'); - --- Estados de orden -CREATE TYPE order_status_enum AS ENUM ('pending', 'open', 'filled', 'cancelled', 'expired'); - --- Tipos de transacción financiera -CREATE TYPE transaction_type_enum AS ENUM ( - 'deposit', - 'withdrawal', - 'subscription_payment', - 'course_purchase', - 'investment_deposit', - 'investment_withdrawal', - 'profit_distribution', - 'fee', - 'refund' -); - --- ============================================================================ --- FUNCIÓN PARA TIMESTAMPS AUTOMÁTICOS --- ============================================================================ - -CREATE OR REPLACE FUNCTION update_updated_at_column() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = CURRENT_TIMESTAMP; - RETURN NEW; -END; -$$ language 'plpgsql'; - --- ============================================================================ --- FUNCIÓN PARA AUDITORÍA AUTOMÁTICA --- ============================================================================ - -CREATE OR REPLACE FUNCTION audit.log_changes() -RETURNS TRIGGER AS $$ -BEGIN - INSERT INTO audit.audit_logs ( - table_name, - record_id, - action, - old_data, - new_data, - user_id, - ip_address - ) VALUES ( - TG_TABLE_SCHEMA || '.' || TG_TABLE_NAME, - COALESCE(NEW.id, OLD.id)::text, - TG_OP, - CASE WHEN TG_OP = 'DELETE' THEN row_to_json(OLD) ELSE NULL END, - CASE WHEN TG_OP IN ('INSERT', 'UPDATE') THEN row_to_json(NEW) ELSE NULL END, - current_setting('app.current_user_id', true)::uuid, - current_setting('app.client_ip', true) - ); - RETURN COALESCE(NEW, OLD); -END; -$$ LANGUAGE plpgsql; diff --git a/apps/database/schemas/01_public_schema.sql b/apps/database/schemas/01_public_schema.sql deleted file mode 100644 index c9b54a5..0000000 --- a/apps/database/schemas/01_public_schema.sql +++ /dev/null @@ -1,280 +0,0 @@ --- ============================================================================ --- OrbiQuant IA - Esquema PUBLIC --- ============================================================================ --- Archivo: 01_public_schema.sql --- Descripción: Usuarios, perfiles, autenticación y configuración --- Fecha: 2025-12-05 --- ============================================================================ - -SET search_path TO public; - --- ============================================================================ --- TABLA: users --- Descripción: Usuarios base del sistema (compatible con Supabase Auth) --- ============================================================================ -CREATE TABLE IF NOT EXISTS users ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - email VARCHAR(255) NOT NULL UNIQUE, - email_verified BOOLEAN DEFAULT FALSE, - phone VARCHAR(20), - phone_verified BOOLEAN DEFAULT FALSE, - encrypted_password VARCHAR(255), - - -- Metadata de auth - confirmation_token VARCHAR(255), - confirmation_sent_at TIMESTAMPTZ, - confirmed_at TIMESTAMPTZ, - recovery_token VARCHAR(255), - recovery_sent_at TIMESTAMPTZ, - - -- 2FA - totp_secret VARCHAR(255), - totp_enabled BOOLEAN DEFAULT FALSE, - backup_codes TEXT[], - - -- Control de acceso - role user_role_enum DEFAULT 'investor', - status status_enum DEFAULT 'pending', - failed_login_attempts INT DEFAULT 0, - locked_until TIMESTAMPTZ, - last_login_at TIMESTAMPTZ, - last_login_ip INET, - - -- Timestamps - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_users_email ON users(email); -CREATE INDEX idx_users_status ON users(status); -CREATE INDEX idx_users_role ON users(role); - --- ============================================================================ --- TABLA: profiles --- Descripción: Información extendida del usuario --- ============================================================================ -CREATE TABLE IF NOT EXISTS profiles ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - user_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, - - -- Datos personales - first_name VARCHAR(100), - last_name VARCHAR(100), - display_name VARCHAR(100), - avatar_url TEXT, - date_of_birth DATE, - - -- Ubicación - country_code CHAR(2), - timezone VARCHAR(50) DEFAULT 'UTC', - language VARCHAR(5) DEFAULT 'es', - - -- Preferencias - preferred_currency CHAR(3) DEFAULT 'USD', - - -- Timestamps - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_profiles_user_id ON profiles(user_id); - --- ============================================================================ --- TABLA: user_settings --- Descripción: Configuraciones y preferencias del usuario --- ============================================================================ -CREATE TABLE IF NOT EXISTS user_settings ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - user_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, - - -- Notificaciones - email_notifications BOOLEAN DEFAULT TRUE, - push_notifications BOOLEAN DEFAULT TRUE, - sms_notifications BOOLEAN DEFAULT FALSE, - - -- Alertas de trading - price_alerts BOOLEAN DEFAULT TRUE, - signal_alerts BOOLEAN DEFAULT TRUE, - portfolio_alerts BOOLEAN DEFAULT TRUE, - - -- Reportes - weekly_report BOOLEAN DEFAULT TRUE, - monthly_report BOOLEAN DEFAULT TRUE, - - -- Privacidad - profile_public BOOLEAN DEFAULT FALSE, - show_portfolio_value BOOLEAN DEFAULT FALSE, - - -- UI - theme VARCHAR(20) DEFAULT 'dark', - chart_preferences JSONB DEFAULT '{}', - - -- Timestamps - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - --- ============================================================================ --- TABLA: kyc_verifications --- Descripción: Verificación de identidad (KYC) --- ============================================================================ -CREATE TYPE kyc_status_enum AS ENUM ('pending', 'submitted', 'approved', 'rejected', 'expired'); -CREATE TYPE kyc_document_type AS ENUM ('passport', 'national_id', 'drivers_license', 'residence_permit'); - -CREATE TABLE IF NOT EXISTS kyc_verifications ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - - -- Estado - status kyc_status_enum DEFAULT 'pending', - level INT DEFAULT 0, -- 0: ninguno, 1: básico, 2: completo - - -- Documentos - document_type kyc_document_type, - document_number VARCHAR(50), - document_country CHAR(2), - document_expiry DATE, - document_front_url TEXT, - document_back_url TEXT, - selfie_url TEXT, - proof_of_address_url TEXT, - - -- Verificación - verified_at TIMESTAMPTZ, - verified_by UUID REFERENCES users(id), - rejection_reason TEXT, - - -- Metadata - verification_provider VARCHAR(50), - external_verification_id VARCHAR(100), - - -- Timestamps - submitted_at TIMESTAMPTZ, - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_kyc_user_id ON kyc_verifications(user_id); -CREATE INDEX idx_kyc_status ON kyc_verifications(status); - --- ============================================================================ --- TABLA: risk_profiles --- Descripción: Perfil de riesgo del usuario (cuestionario) --- ============================================================================ -CREATE TABLE IF NOT EXISTS risk_profiles ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - - -- Resultado - profile risk_profile_enum NOT NULL, - score INT, -- 0-100 - - -- Respuestas del cuestionario (JSON) - questionnaire_answers JSONB NOT NULL, - - -- Parámetros derivados - max_drawdown_tolerance DECIMAL(5,2), -- % máximo de pérdida tolerada - investment_horizon_months INT, -- Horizonte en meses - experience_level INT, -- 1-5 - - -- Validez - valid_until TIMESTAMPTZ, - - -- Timestamps - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_risk_profiles_user_id ON risk_profiles(user_id); - --- ============================================================================ --- TABLA: sessions --- Descripción: Sesiones activas de usuarios --- ============================================================================ -CREATE TABLE IF NOT EXISTS sessions ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - - -- Token - refresh_token VARCHAR(255) NOT NULL UNIQUE, - - -- Información del dispositivo - user_agent TEXT, - ip_address INET, - device_info JSONB, - - -- Validez - expires_at TIMESTAMPTZ NOT NULL, - revoked_at TIMESTAMPTZ, - - -- Timestamps - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - last_active_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_sessions_user_id ON sessions(user_id); -CREATE INDEX idx_sessions_refresh_token ON sessions(refresh_token); -CREATE INDEX idx_sessions_expires_at ON sessions(expires_at); - --- ============================================================================ --- TABLA: notifications --- Descripción: Notificaciones del sistema --- ============================================================================ -CREATE TYPE notification_type_enum AS ENUM ( - 'system', 'trading', 'investment', 'education', 'payment', 'security', 'marketing' -); - -CREATE TABLE IF NOT EXISTS notifications ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - - -- Contenido - type notification_type_enum NOT NULL, - title VARCHAR(255) NOT NULL, - message TEXT NOT NULL, - data JSONB, - - -- Estado - read_at TIMESTAMPTZ, - - -- Canales enviados - sent_email BOOLEAN DEFAULT FALSE, - sent_push BOOLEAN DEFAULT FALSE, - sent_sms BOOLEAN DEFAULT FALSE, - - -- Timestamps - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_notifications_user_id ON notifications(user_id); -CREATE INDEX idx_notifications_read ON notifications(user_id, read_at); -CREATE INDEX idx_notifications_created ON notifications(created_at DESC); - --- ============================================================================ --- TRIGGERS --- ============================================================================ - -CREATE TRIGGER update_users_updated_at - BEFORE UPDATE ON users - FOR EACH ROW - EXECUTE FUNCTION update_updated_at_column(); - -CREATE TRIGGER update_profiles_updated_at - BEFORE UPDATE ON profiles - FOR EACH ROW - EXECUTE FUNCTION update_updated_at_column(); - -CREATE TRIGGER update_user_settings_updated_at - BEFORE UPDATE ON user_settings - FOR EACH ROW - EXECUTE FUNCTION update_updated_at_column(); - -CREATE TRIGGER update_kyc_updated_at - BEFORE UPDATE ON kyc_verifications - FOR EACH ROW - EXECUTE FUNCTION update_updated_at_column(); - -CREATE TRIGGER update_risk_profiles_updated_at - BEFORE UPDATE ON risk_profiles - FOR EACH ROW - EXECUTE FUNCTION update_updated_at_column(); diff --git a/apps/database/schemas/01b_oauth_providers.sql b/apps/database/schemas/01b_oauth_providers.sql deleted file mode 100644 index afda232..0000000 --- a/apps/database/schemas/01b_oauth_providers.sql +++ /dev/null @@ -1,220 +0,0 @@ --- ============================================================================ --- OrbiQuant IA - OAuth Providers --- ============================================================================ --- Archivo: 01b_oauth_providers.sql --- Descripción: Proveedores OAuth y vinculación de cuentas sociales --- Fecha: 2025-12-05 --- ============================================================================ - -SET search_path TO public; - --- ============================================================================ --- ENUM: auth_provider_enum --- ============================================================================ -CREATE TYPE auth_provider_enum AS ENUM ( - 'email', -- Email/password tradicional - 'phone', -- Teléfono (SMS/WhatsApp) - 'google', -- Google OAuth - 'facebook', -- Facebook OAuth - 'twitter', -- X (Twitter) OAuth - 'apple', -- Apple Sign In - 'github' -- GitHub OAuth -); - --- ============================================================================ --- TABLA: oauth_accounts --- Descripción: Cuentas OAuth vinculadas a usuarios --- ============================================================================ -CREATE TABLE IF NOT EXISTS oauth_accounts ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - - -- Proveedor - provider auth_provider_enum NOT NULL, - provider_account_id VARCHAR(255) NOT NULL, - - -- Tokens OAuth - access_token TEXT, - refresh_token TEXT, - token_expires_at TIMESTAMPTZ, - token_type VARCHAR(50), - scope TEXT, - - -- Datos del perfil del proveedor - provider_email VARCHAR(255), - provider_name VARCHAR(255), - provider_avatar_url TEXT, - provider_profile JSONB, - - -- Timestamps - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - - -- Constraints - UNIQUE(provider, provider_account_id), - UNIQUE(user_id, provider) -); - -CREATE INDEX idx_oauth_user_id ON oauth_accounts(user_id); -CREATE INDEX idx_oauth_provider ON oauth_accounts(provider); -CREATE INDEX idx_oauth_provider_account ON oauth_accounts(provider, provider_account_id); - --- ============================================================================ --- TABLA: phone_verifications --- Descripción: Verificaciones de teléfono (OTP via SMS/WhatsApp) --- ============================================================================ -CREATE TYPE phone_channel_enum AS ENUM ('sms', 'whatsapp', 'call'); - -CREATE TABLE IF NOT EXISTS phone_verifications ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - - -- Teléfono - phone_number VARCHAR(20) NOT NULL, - country_code VARCHAR(5) NOT NULL, - - -- OTP - otp_code VARCHAR(6) NOT NULL, - otp_hash VARCHAR(255) NOT NULL, - channel phone_channel_enum DEFAULT 'whatsapp', - - -- Estado - verified BOOLEAN DEFAULT FALSE, - attempts INT DEFAULT 0, - max_attempts INT DEFAULT 3, - - -- Vinculación - user_id UUID REFERENCES users(id) ON DELETE SET NULL, - purpose VARCHAR(50) NOT NULL DEFAULT 'login', -- login, register, verify, 2fa - - -- Validez - expires_at TIMESTAMPTZ NOT NULL, - verified_at TIMESTAMPTZ, - - -- Timestamps - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_phone_verif_phone ON phone_verifications(phone_number); -CREATE INDEX idx_phone_verif_expires ON phone_verifications(expires_at); -CREATE INDEX idx_phone_verif_user ON phone_verifications(user_id); - --- ============================================================================ --- TABLA: email_verifications --- Descripción: Tokens de verificación de email --- ============================================================================ -CREATE TABLE IF NOT EXISTS email_verifications ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - - -- Email - email VARCHAR(255) NOT NULL, - - -- Token - token VARCHAR(255) NOT NULL UNIQUE, - token_hash VARCHAR(255) NOT NULL, - - -- Vinculación - user_id UUID REFERENCES users(id) ON DELETE CASCADE, - purpose VARCHAR(50) NOT NULL DEFAULT 'verify', -- verify, reset_password, change_email - - -- Estado - used BOOLEAN DEFAULT FALSE, - - -- Validez - expires_at TIMESTAMPTZ NOT NULL, - used_at TIMESTAMPTZ, - - -- Timestamps - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_email_verif_email ON email_verifications(email); -CREATE INDEX idx_email_verif_token ON email_verifications(token_hash); -CREATE INDEX idx_email_verif_user ON email_verifications(user_id); - --- ============================================================================ --- TABLA: auth_logs --- Descripción: Logs de eventos de autenticación --- ============================================================================ -CREATE TYPE auth_event_enum AS ENUM ( - 'login_success', - 'login_failed', - 'logout', - 'register', - 'password_reset_request', - 'password_reset_complete', - 'email_verified', - 'phone_verified', - 'oauth_linked', - 'oauth_unlinked', - '2fa_enabled', - '2fa_disabled', - '2fa_verified', - 'session_revoked', - 'account_locked', - 'account_unlocked' -); - -CREATE TABLE IF NOT EXISTS auth_logs ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - user_id UUID REFERENCES users(id) ON DELETE SET NULL, - - -- Evento - event auth_event_enum NOT NULL, - provider auth_provider_enum, - - -- Contexto - ip_address INET, - user_agent TEXT, - device_fingerprint VARCHAR(255), - location JSONB, -- {country, city, region} - - -- Metadata - metadata JSONB, - - -- Resultado - success BOOLEAN DEFAULT TRUE, - error_message TEXT, - - -- Timestamps - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_auth_logs_user ON auth_logs(user_id); -CREATE INDEX idx_auth_logs_event ON auth_logs(event); -CREATE INDEX idx_auth_logs_created ON auth_logs(created_at DESC); -CREATE INDEX idx_auth_logs_ip ON auth_logs(ip_address); - --- ============================================================================ --- MODIFICAR TABLA users: agregar campo auth_provider --- ============================================================================ -ALTER TABLE users ADD COLUMN IF NOT EXISTS primary_auth_provider auth_provider_enum DEFAULT 'email'; - --- ============================================================================ --- TRIGGERS --- ============================================================================ - -CREATE TRIGGER update_oauth_accounts_updated_at - BEFORE UPDATE ON oauth_accounts - FOR EACH ROW - EXECUTE FUNCTION update_updated_at_column(); - --- ============================================================================ --- FUNCIONES DE UTILIDAD --- ============================================================================ - --- Función para generar OTP de 6 dígitos -CREATE OR REPLACE FUNCTION generate_otp() -RETURNS VARCHAR(6) AS $$ -BEGIN - RETURN LPAD(FLOOR(RANDOM() * 1000000)::TEXT, 6, '0'); -END; -$$ LANGUAGE plpgsql; - --- Función para limpiar verificaciones expiradas -CREATE OR REPLACE FUNCTION cleanup_expired_verifications() -RETURNS void AS $$ -BEGIN - DELETE FROM phone_verifications WHERE expires_at < NOW() AND verified = FALSE; - DELETE FROM email_verifications WHERE expires_at < NOW() AND used = FALSE; -END; -$$ LANGUAGE plpgsql; diff --git a/apps/database/schemas/02_education_schema.sql b/apps/database/schemas/02_education_schema.sql deleted file mode 100644 index 12f95a5..0000000 --- a/apps/database/schemas/02_education_schema.sql +++ /dev/null @@ -1,398 +0,0 @@ --- ============================================================================ --- OrbiQuant IA - Esquema EDUCATION --- ============================================================================ --- Archivo: 02_education_schema.sql --- Descripción: Cursos, lecciones, inscripciones y contenido educativo --- Fecha: 2025-12-05 --- ============================================================================ - -SET search_path TO education; - --- ============================================================================ --- TIPOS ENUMERADOS --- ============================================================================ - -CREATE TYPE course_level_enum AS ENUM ('beginner', 'intermediate', 'advanced', 'expert'); -CREATE TYPE course_status_enum AS ENUM ('draft', 'published', 'archived'); -CREATE TYPE content_type_enum AS ENUM ('video', 'text', 'quiz', 'exercise', 'resource'); -CREATE TYPE enrollment_status_enum AS ENUM ('active', 'completed', 'expired', 'cancelled'); - --- ============================================================================ --- TABLA: categories --- Descripción: Categorías de cursos --- ============================================================================ -CREATE TABLE IF NOT EXISTS categories ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - name VARCHAR(100) NOT NULL, - slug VARCHAR(100) NOT NULL UNIQUE, - description TEXT, - icon VARCHAR(50), - parent_id UUID REFERENCES categories(id), - sort_order INT DEFAULT 0, - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_categories_parent ON categories(parent_id); -CREATE INDEX idx_categories_slug ON categories(slug); - --- ============================================================================ --- TABLA: courses --- Descripción: Cursos de trading --- ============================================================================ -CREATE TABLE IF NOT EXISTS courses ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - - -- Información básica - title VARCHAR(255) NOT NULL, - slug VARCHAR(255) NOT NULL UNIQUE, - description TEXT, - short_description VARCHAR(500), - thumbnail_url TEXT, - preview_video_url TEXT, - - -- Categorización - category_id UUID REFERENCES categories(id), - level course_level_enum DEFAULT 'beginner', - tags TEXT[], - - -- Pricing - is_free BOOLEAN DEFAULT FALSE, - price DECIMAL(10,2) DEFAULT 0, - currency CHAR(3) DEFAULT 'USD', - - -- Acceso - requires_subscription BOOLEAN DEFAULT FALSE, - min_subscription_tier VARCHAR(20), -- 'basic', 'pro', 'elite' - - -- Metadata - duration_minutes INT, - lessons_count INT DEFAULT 0, - enrolled_count INT DEFAULT 0, - - -- Rating - average_rating DECIMAL(3,2) DEFAULT 0, - ratings_count INT DEFAULT 0, - - -- Estado - status course_status_enum DEFAULT 'draft', - published_at TIMESTAMPTZ, - - -- Autor - instructor_id UUID REFERENCES public.users(id), - - -- AI Generation - ai_generated BOOLEAN DEFAULT FALSE, - ai_generation_prompt TEXT, - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_courses_slug ON courses(slug); -CREATE INDEX idx_courses_category ON courses(category_id); -CREATE INDEX idx_courses_status ON courses(status); -CREATE INDEX idx_courses_level ON courses(level); -CREATE INDEX idx_courses_instructor ON courses(instructor_id); - --- ============================================================================ --- TABLA: modules --- Descripción: Módulos/secciones de un curso --- ============================================================================ -CREATE TABLE IF NOT EXISTS modules ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE, - - title VARCHAR(255) NOT NULL, - description TEXT, - sort_order INT DEFAULT 0, - - -- Progreso requerido del módulo anterior para desbloquear - unlock_after_module_id UUID REFERENCES modules(id), - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_modules_course ON modules(course_id); - --- ============================================================================ --- TABLA: lessons --- Descripción: Lecciones individuales --- ============================================================================ -CREATE TABLE IF NOT EXISTS lessons ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - module_id UUID NOT NULL REFERENCES modules(id) ON DELETE CASCADE, - course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE, - - -- Contenido - title VARCHAR(255) NOT NULL, - slug VARCHAR(255) NOT NULL, - content_type content_type_enum DEFAULT 'video', - - -- Video - video_url TEXT, - video_duration_seconds INT, - video_provider VARCHAR(50), -- 'youtube', 'vimeo', 'internal' - - -- Texto - content_markdown TEXT, - content_html TEXT, - - -- Recursos adicionales - resources JSONB DEFAULT '[]', -- [{name, url, type}] - - -- Orden - sort_order INT DEFAULT 0, - - -- Acceso - is_preview BOOLEAN DEFAULT FALSE, -- Disponible sin inscripción - - -- AI - ai_generated BOOLEAN DEFAULT FALSE, - ai_summary TEXT, - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - - UNIQUE(course_id, slug) -); - -CREATE INDEX idx_lessons_module ON lessons(module_id); -CREATE INDEX idx_lessons_course ON lessons(course_id); - --- ============================================================================ --- TABLA: quizzes --- Descripción: Cuestionarios y evaluaciones --- ============================================================================ -CREATE TABLE IF NOT EXISTS quizzes ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - lesson_id UUID REFERENCES lessons(id) ON DELETE CASCADE, - course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE, - - title VARCHAR(255) NOT NULL, - description TEXT, - - -- Configuración - passing_score INT DEFAULT 70, -- Porcentaje mínimo - max_attempts INT, -- NULL = ilimitado - time_limit_minutes INT, - shuffle_questions BOOLEAN DEFAULT FALSE, - show_correct_answers BOOLEAN DEFAULT TRUE, - - -- AI - ai_generated BOOLEAN DEFAULT FALSE, - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_quizzes_lesson ON quizzes(lesson_id); -CREATE INDEX idx_quizzes_course ON quizzes(course_id); - --- ============================================================================ --- TABLA: quiz_questions --- Descripción: Preguntas de cuestionarios --- ============================================================================ -CREATE TYPE question_type_enum AS ENUM ('multiple_choice', 'true_false', 'multiple_answer', 'short_answer'); - -CREATE TABLE IF NOT EXISTS quiz_questions ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - quiz_id UUID NOT NULL REFERENCES quizzes(id) ON DELETE CASCADE, - - question_type question_type_enum DEFAULT 'multiple_choice', - question_text TEXT NOT NULL, - explanation TEXT, -- Explicación mostrada después de responder - - -- Opciones (para multiple_choice y multiple_answer) - options JSONB, -- [{id, text, is_correct}] - - -- Para short_answer - correct_answers TEXT[], -- Respuestas aceptadas - - points INT DEFAULT 1, - sort_order INT DEFAULT 0, - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_quiz_questions_quiz ON quiz_questions(quiz_id); - --- ============================================================================ --- TABLA: enrollments --- Descripción: Inscripciones de usuarios a cursos --- ============================================================================ -CREATE TABLE IF NOT EXISTS enrollments ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE, - - -- Estado - status enrollment_status_enum DEFAULT 'active', - - -- Progreso - progress_percentage DECIMAL(5,2) DEFAULT 0, - lessons_completed INT DEFAULT 0, - - -- Acceso - enrolled_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - expires_at TIMESTAMPTZ, -- NULL = acceso permanente - completed_at TIMESTAMPTZ, - - -- Pago (si aplica) - payment_id UUID, -- Referencia a financial.payments - - -- Certificado - certificate_issued BOOLEAN DEFAULT FALSE, - certificate_url TEXT, - certificate_issued_at TIMESTAMPTZ, - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - - UNIQUE(user_id, course_id) -); - -CREATE INDEX idx_enrollments_user ON enrollments(user_id); -CREATE INDEX idx_enrollments_course ON enrollments(course_id); -CREATE INDEX idx_enrollments_status ON enrollments(status); - --- ============================================================================ --- TABLA: lesson_progress --- Descripción: Progreso por lección --- ============================================================================ -CREATE TABLE IF NOT EXISTS lesson_progress ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - lesson_id UUID NOT NULL REFERENCES lessons(id) ON DELETE CASCADE, - enrollment_id UUID NOT NULL REFERENCES enrollments(id) ON DELETE CASCADE, - - -- Progreso de video - video_watched_seconds INT DEFAULT 0, - video_completed BOOLEAN DEFAULT FALSE, - - -- Estado - started_at TIMESTAMPTZ, - completed_at TIMESTAMPTZ, - - -- Notas del usuario - user_notes TEXT, - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - - UNIQUE(user_id, lesson_id) -); - -CREATE INDEX idx_lesson_progress_user ON lesson_progress(user_id); -CREATE INDEX idx_lesson_progress_lesson ON lesson_progress(lesson_id); -CREATE INDEX idx_lesson_progress_enrollment ON lesson_progress(enrollment_id); - --- ============================================================================ --- TABLA: quiz_attempts --- Descripción: Intentos de cuestionarios --- ============================================================================ -CREATE TABLE IF NOT EXISTS quiz_attempts ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - quiz_id UUID NOT NULL REFERENCES quizzes(id) ON DELETE CASCADE, - enrollment_id UUID REFERENCES enrollments(id) ON DELETE SET NULL, - - -- Resultado - score DECIMAL(5,2), - passed BOOLEAN, - - -- Respuestas - answers JSONB, -- [{question_id, answer, is_correct}] - - -- Tiempo - started_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - submitted_at TIMESTAMPTZ, - time_spent_seconds INT, - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_quiz_attempts_user ON quiz_attempts(user_id); -CREATE INDEX idx_quiz_attempts_quiz ON quiz_attempts(quiz_id); - --- ============================================================================ --- TABLA: course_reviews --- Descripción: Reseñas de cursos --- ============================================================================ -CREATE TABLE IF NOT EXISTS course_reviews ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, - course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE, - - rating INT NOT NULL CHECK (rating >= 1 AND rating <= 5), - review_text TEXT, - - -- Moderación - is_approved BOOLEAN DEFAULT TRUE, - is_featured BOOLEAN DEFAULT FALSE, - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - - UNIQUE(user_id, course_id) -); - -CREATE INDEX idx_course_reviews_course ON course_reviews(course_id); - --- ============================================================================ --- TABLA: ai_content_generations --- Descripción: Registro de contenido generado por IA --- ============================================================================ -CREATE TABLE IF NOT EXISTS ai_content_generations ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - - -- Referencia - entity_type VARCHAR(50) NOT NULL, -- 'course', 'lesson', 'quiz' - entity_id UUID NOT NULL, - - -- Generación - prompt TEXT NOT NULL, - model_used VARCHAR(100), - generated_content TEXT NOT NULL, - tokens_used INT, - - -- Estado - approved BOOLEAN DEFAULT FALSE, - approved_by UUID REFERENCES public.users(id), - approved_at TIMESTAMPTZ, - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_ai_generations_entity ON ai_content_generations(entity_type, entity_id); - --- ============================================================================ --- TRIGGERS --- ============================================================================ - -CREATE TRIGGER update_courses_updated_at - BEFORE UPDATE ON courses - FOR EACH ROW - EXECUTE FUNCTION public.update_updated_at_column(); - -CREATE TRIGGER update_modules_updated_at - BEFORE UPDATE ON modules - FOR EACH ROW - EXECUTE FUNCTION public.update_updated_at_column(); - -CREATE TRIGGER update_lessons_updated_at - BEFORE UPDATE ON lessons - FOR EACH ROW - EXECUTE FUNCTION public.update_updated_at_column(); - -CREATE TRIGGER update_enrollments_updated_at - BEFORE UPDATE ON enrollments - FOR EACH ROW - EXECUTE FUNCTION public.update_updated_at_column(); - -CREATE TRIGGER update_lesson_progress_updated_at - BEFORE UPDATE ON lesson_progress - FOR EACH ROW - EXECUTE FUNCTION public.update_updated_at_column(); diff --git a/apps/database/schemas/03_trading_schema.sql b/apps/database/schemas/03_trading_schema.sql deleted file mode 100644 index bfa2a7a..0000000 --- a/apps/database/schemas/03_trading_schema.sql +++ /dev/null @@ -1,428 +0,0 @@ --- ============================================================================ --- OrbiQuant IA - Esquema TRADING --- ============================================================================ --- Archivo: 03_trading_schema.sql --- Descripción: Bots, señales, estrategias y operaciones de trading --- Fecha: 2025-12-05 --- ============================================================================ - -SET search_path TO trading; - --- ============================================================================ --- TIPOS ENUMERADOS --- ============================================================================ - -CREATE TYPE bot_status_enum AS ENUM ('active', 'paused', 'stopped', 'error', 'maintenance'); -CREATE TYPE signal_status_enum AS ENUM ('pending', 'active', 'triggered', 'expired', 'cancelled'); -CREATE TYPE position_status_enum AS ENUM ('open', 'closed', 'pending'); -CREATE TYPE timeframe_enum AS ENUM ('1m', '5m', '15m', '30m', '1h', '4h', '1d', '1w'); -CREATE TYPE amd_phase_enum AS ENUM ('accumulation', 'manipulation', 'distribution', 'unknown'); -CREATE TYPE volatility_regime_enum AS ENUM ('low', 'medium', 'high', 'extreme'); - --- ============================================================================ --- TABLA: symbols --- Descripción: Instrumentos financieros disponibles --- ============================================================================ -CREATE TABLE IF NOT EXISTS symbols ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - - -- Identificación - symbol VARCHAR(20) NOT NULL UNIQUE, -- 'XAUUSD', 'EURUSD', 'BTCUSD' - name VARCHAR(100) NOT NULL, - description TEXT, - - -- Clasificación - asset_class VARCHAR(50), -- 'forex', 'crypto', 'commodities', 'indices', 'stocks' - base_currency VARCHAR(10), - quote_currency VARCHAR(10), - - -- Trading info - pip_value DECIMAL(10,6), - lot_size DECIMAL(10,2), - min_lot DECIMAL(10,4), - max_lot DECIMAL(10,2), - tick_size DECIMAL(10,6), - - -- Estado - is_active BOOLEAN DEFAULT TRUE, - trading_hours JSONB, -- [{day, open, close}] - - -- Data provider - data_provider VARCHAR(50), - provider_symbol VARCHAR(50), - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_symbols_symbol ON symbols(symbol); -CREATE INDEX idx_symbols_asset_class ON symbols(asset_class); - --- ============================================================================ --- TABLA: strategies --- Descripción: Estrategias de trading disponibles --- ============================================================================ -CREATE TABLE IF NOT EXISTS strategies ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - - name VARCHAR(100) NOT NULL, - slug VARCHAR(100) NOT NULL UNIQUE, - description TEXT, - - -- Tipo - strategy_type VARCHAR(50), -- 'intraday', 'swing', 'scalping', 'position' - - -- Configuración - default_timeframe timeframe_enum DEFAULT '15m', - supported_symbols TEXT[], -- NULL = todos - risk_reward_ratio DECIMAL(4,2) DEFAULT 2.0, - - -- Parámetros configurables (JSON Schema) - parameters_schema JSONB, - default_parameters JSONB, - - -- ML Model asociado - uses_ml_model BOOLEAN DEFAULT TRUE, - ml_model_id UUID, -- Referencia a ml.models - - -- AMD - uses_amd BOOLEAN DEFAULT TRUE, - favorable_phases amd_phase_enum[], - - -- Estado - is_active BOOLEAN DEFAULT TRUE, - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_strategies_slug ON strategies(slug); - --- ============================================================================ --- TABLA: bots (Agentes de Trading) --- Descripción: Instancias de bots/agentes de trading --- ============================================================================ -CREATE TABLE IF NOT EXISTS bots ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - - -- Identificación - name VARCHAR(100) NOT NULL, - slug VARCHAR(100) NOT NULL UNIQUE, - description TEXT, - avatar_url TEXT, - - -- Perfil de riesgo - risk_profile public.risk_profile_enum NOT NULL, - - -- Objetivos - target_monthly_return DECIMAL(5,2), -- % objetivo - max_drawdown DECIMAL(5,2), -- % máximo - max_position_size DECIMAL(5,2), -- % del capital - - -- Estrategias - strategy_id UUID REFERENCES strategies(id), - strategy_parameters JSONB, - - -- Configuración - supported_symbols TEXT[], - default_timeframe timeframe_enum DEFAULT '15m', - min_confidence DECIMAL(3,2) DEFAULT 0.55, - - -- Horarios de operación - trading_schedule JSONB, -- Horarios permitidos - - -- Estado - status bot_status_enum DEFAULT 'stopped', - last_activity_at TIMESTAMPTZ, - error_message TEXT, - - -- Métricas globales (actualizadas periódicamente) - total_trades INT DEFAULT 0, - winning_trades INT DEFAULT 0, - total_profit DECIMAL(15,2) DEFAULT 0, - sharpe_ratio DECIMAL(5,2), - sortino_ratio DECIMAL(5,2), - max_drawdown_actual DECIMAL(5,2), - - -- Público - is_public BOOLEAN DEFAULT TRUE, -- Visible para asignar a cuentas - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_bots_slug ON bots(slug); -CREATE INDEX idx_bots_risk_profile ON bots(risk_profile); -CREATE INDEX idx_bots_status ON bots(status); - --- ============================================================================ --- TABLA: signals --- Descripción: Señales de trading generadas --- ============================================================================ -CREATE TABLE IF NOT EXISTS signals ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - - -- Origen - bot_id UUID REFERENCES bots(id), - strategy_id UUID REFERENCES strategies(id), - symbol_id UUID REFERENCES symbols(id), - symbol VARCHAR(20) NOT NULL, - - -- Señal - direction public.trade_direction_enum NOT NULL, - timeframe timeframe_enum NOT NULL, - - -- Precios - entry_price DECIMAL(20,8) NOT NULL, - stop_loss DECIMAL(20,8) NOT NULL, - take_profit DECIMAL(20,8) NOT NULL, - current_price DECIMAL(20,8), - - -- Predicción ML - predicted_delta_high DECIMAL(20,8), - predicted_delta_low DECIMAL(20,8), - prob_tp_first DECIMAL(5,4), -- Probabilidad de tocar TP antes que SL - confidence_score DECIMAL(5,4), - - -- Contexto de mercado - amd_phase amd_phase_enum, - volatility_regime volatility_regime_enum, - market_context JSONB, -- Datos adicionales del contexto - - -- R:R - risk_reward_ratio DECIMAL(4,2), - risk_pips DECIMAL(10,4), - reward_pips DECIMAL(10,4), - - -- Estado - status signal_status_enum DEFAULT 'pending', - triggered_at TIMESTAMPTZ, - expired_at TIMESTAMPTZ, - - -- Resultado (si se operó) - outcome VARCHAR(20), -- 'tp_hit', 'sl_hit', 'manual_close', 'expired' - pnl DECIMAL(15,2), - - -- Timestamps - valid_until TIMESTAMPTZ, - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_signals_bot ON signals(bot_id); -CREATE INDEX idx_signals_symbol ON signals(symbol); -CREATE INDEX idx_signals_status ON signals(status); -CREATE INDEX idx_signals_created ON signals(created_at DESC); -CREATE INDEX idx_signals_direction ON signals(direction); - --- ============================================================================ --- TABLA: positions --- Descripción: Posiciones de trading (abiertas y cerradas) --- ============================================================================ -CREATE TABLE IF NOT EXISTS positions ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - - -- Referencias - bot_id UUID REFERENCES bots(id), - signal_id UUID REFERENCES signals(id), - investment_account_id UUID, -- Referencia a investment.accounts - symbol_id UUID REFERENCES symbols(id), - symbol VARCHAR(20) NOT NULL, - - -- Posición - direction public.trade_direction_enum NOT NULL, - lot_size DECIMAL(10,4) NOT NULL, - - -- Precios - entry_price DECIMAL(20,8) NOT NULL, - stop_loss DECIMAL(20,8), - take_profit DECIMAL(20,8), - exit_price DECIMAL(20,8), - - -- Broker - broker_order_id VARCHAR(100), - broker_position_id VARCHAR(100), - - -- Estado - status position_status_enum DEFAULT 'open', - opened_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - closed_at TIMESTAMPTZ, - close_reason VARCHAR(50), -- 'tp', 'sl', 'manual', 'forced', 'margin_call' - - -- P&L - realized_pnl DECIMAL(15,2), - unrealized_pnl DECIMAL(15,2), - commission DECIMAL(10,2) DEFAULT 0, - swap DECIMAL(10,2) DEFAULT 0, - - -- Metadata - metadata JSONB, - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_positions_bot ON positions(bot_id); -CREATE INDEX idx_positions_signal ON positions(signal_id); -CREATE INDEX idx_positions_account ON positions(investment_account_id); -CREATE INDEX idx_positions_status ON positions(status); -CREATE INDEX idx_positions_opened ON positions(opened_at DESC); - --- ============================================================================ --- TABLA: price_alerts --- Descripción: Alertas de precio configuradas por usuarios --- ============================================================================ -CREATE TYPE alert_condition_enum AS ENUM ('above', 'below', 'crosses_above', 'crosses_below'); - -CREATE TABLE IF NOT EXISTS price_alerts ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - - symbol VARCHAR(20) NOT NULL, - condition alert_condition_enum NOT NULL, - price DECIMAL(20,8) NOT NULL, - note TEXT, - - -- Estado - is_active BOOLEAN DEFAULT TRUE, - triggered_at TIMESTAMPTZ, - triggered_price DECIMAL(20,8), - - -- Notificación - notify_email BOOLEAN DEFAULT TRUE, - notify_push BOOLEAN DEFAULT TRUE, - - -- Recurrencia - is_recurring BOOLEAN DEFAULT FALSE, -- Si se reactiva después de triggear - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_price_alerts_user ON price_alerts(user_id); -CREATE INDEX idx_price_alerts_symbol ON price_alerts(symbol); -CREATE INDEX idx_price_alerts_active ON price_alerts(is_active) WHERE is_active = TRUE; - --- ============================================================================ --- TABLA: watchlists --- Descripción: Listas de seguimiento de usuarios --- ============================================================================ -CREATE TABLE IF NOT EXISTS watchlists ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - - name VARCHAR(100) NOT NULL, - is_default BOOLEAN DEFAULT FALSE, - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_watchlists_user ON watchlists(user_id); - --- ============================================================================ --- TABLA: watchlist_items --- Descripción: Items en watchlists --- ============================================================================ -CREATE TABLE IF NOT EXISTS watchlist_items ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - watchlist_id UUID NOT NULL REFERENCES watchlists(id) ON DELETE CASCADE, - - symbol VARCHAR(20) NOT NULL, - sort_order INT DEFAULT 0, - notes TEXT, - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - - UNIQUE(watchlist_id, symbol) -); - -CREATE INDEX idx_watchlist_items_watchlist ON watchlist_items(watchlist_id); - --- ============================================================================ --- TABLA: paper_trading_accounts --- Descripción: Cuentas de paper trading (simulación) --- ============================================================================ -CREATE TABLE IF NOT EXISTS paper_trading_accounts ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - - name VARCHAR(100) DEFAULT 'Paper Account', - initial_balance DECIMAL(15,2) NOT NULL DEFAULT 100000, - current_balance DECIMAL(15,2) NOT NULL DEFAULT 100000, - currency CHAR(3) DEFAULT 'USD', - - -- Métricas - total_trades INT DEFAULT 0, - winning_trades INT DEFAULT 0, - total_pnl DECIMAL(15,2) DEFAULT 0, - max_drawdown DECIMAL(5,2) DEFAULT 0, - - is_active BOOLEAN DEFAULT TRUE, - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_paper_accounts_user ON paper_trading_accounts(user_id); - --- ============================================================================ --- TABLA: paper_trading_positions --- Descripción: Posiciones de paper trading --- ============================================================================ -CREATE TABLE IF NOT EXISTS paper_trading_positions ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - account_id UUID NOT NULL REFERENCES paper_trading_accounts(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - - symbol VARCHAR(20) NOT NULL, - direction public.trade_direction_enum NOT NULL, - lot_size DECIMAL(10,4) NOT NULL, - - entry_price DECIMAL(20,8) NOT NULL, - stop_loss DECIMAL(20,8), - take_profit DECIMAL(20,8), - exit_price DECIMAL(20,8), - - status position_status_enum DEFAULT 'open', - opened_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - closed_at TIMESTAMPTZ, - close_reason VARCHAR(50), - - realized_pnl DECIMAL(15,2), - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_paper_positions_account ON paper_trading_positions(account_id); -CREATE INDEX idx_paper_positions_user ON paper_trading_positions(user_id); -CREATE INDEX idx_paper_positions_status ON paper_trading_positions(status); - --- ============================================================================ --- TRIGGERS --- ============================================================================ - -CREATE TRIGGER update_symbols_updated_at - BEFORE UPDATE ON symbols - FOR EACH ROW - EXECUTE FUNCTION public.update_updated_at_column(); - -CREATE TRIGGER update_strategies_updated_at - BEFORE UPDATE ON strategies - FOR EACH ROW - EXECUTE FUNCTION public.update_updated_at_column(); - -CREATE TRIGGER update_bots_updated_at - BEFORE UPDATE ON bots - FOR EACH ROW - EXECUTE FUNCTION public.update_updated_at_column(); - -CREATE TRIGGER update_signals_updated_at - BEFORE UPDATE ON signals - FOR EACH ROW - EXECUTE FUNCTION public.update_updated_at_column(); - -CREATE TRIGGER update_positions_updated_at - BEFORE UPDATE ON positions - FOR EACH ROW - EXECUTE FUNCTION public.update_updated_at_column(); diff --git a/apps/database/schemas/04_investment_schema.sql b/apps/database/schemas/04_investment_schema.sql deleted file mode 100644 index b91cd22..0000000 --- a/apps/database/schemas/04_investment_schema.sql +++ /dev/null @@ -1,426 +0,0 @@ --- ============================================================================ --- OrbiQuant IA - Esquema INVESTMENT --- ============================================================================ --- Archivo: 04_investment_schema.sql --- Descripción: Cuentas de inversión, productos y gestión de portafolios --- Fecha: 2025-12-05 --- ============================================================================ - -SET search_path TO investment; - --- ============================================================================ --- TIPOS ENUMERADOS --- ============================================================================ - -CREATE TYPE product_type_enum AS ENUM ( - 'fixed_return', -- Rendimiento fijo objetivo (ej: 5% mensual) - 'variable_return', -- Rendimiento variable con reparto de utilidades - 'long_term_portfolio' -- Cartera de largo plazo (acciones, ETFs) -); - -CREATE TYPE account_status_enum AS ENUM ( - 'pending_kyc', -- Esperando verificación KYC - 'pending_deposit', -- Esperando depósito inicial - 'active', -- Cuenta activa operando - 'paused', -- Pausada por usuario o admin - 'suspended', -- Suspendida por compliance - 'closed' -- Cerrada -); - -CREATE TYPE fee_type_enum AS ENUM ( - 'management', -- Comisión de administración - 'performance', -- Comisión de rendimiento - 'deposit', -- Comisión de depósito - 'withdrawal', -- Comisión de retiro - 'subscription' -- Comisión de suscripción -); - --- ============================================================================ --- TABLA: products --- Descripción: Productos de inversión disponibles --- ============================================================================ -CREATE TABLE IF NOT EXISTS products ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - - -- Identificación - name VARCHAR(100) NOT NULL, - slug VARCHAR(100) NOT NULL UNIQUE, - description TEXT, - short_description VARCHAR(500), - - -- Tipo y riesgo - product_type product_type_enum NOT NULL, - risk_profile public.risk_profile_enum NOT NULL, - - -- Objetivos - target_monthly_return DECIMAL(5,2), -- % objetivo mensual - max_drawdown DECIMAL(5,2), -- % máximo drawdown permitido - guaranteed_return BOOLEAN DEFAULT FALSE, -- SIEMPRE FALSE para cumplimiento - - -- Comisiones - management_fee_percent DECIMAL(5,2) DEFAULT 0, -- % anual sobre AUM - performance_fee_percent DECIMAL(5,2) DEFAULT 0, -- % sobre ganancias - profit_share_platform DECIMAL(5,2), -- % de utilidades para plataforma (ej: 50) - profit_share_client DECIMAL(5,2), -- % de utilidades para cliente (ej: 50) - - -- Límites - min_investment DECIMAL(15,2) DEFAULT 100, - max_investment DECIMAL(15,2), - min_investment_period_days INT DEFAULT 30, - - -- Restricciones - requires_kyc_level INT DEFAULT 1, - allowed_risk_profiles public.risk_profile_enum[], - - -- Bot/Agente asociado - default_bot_id UUID REFERENCES trading.bots(id), - - -- Estado - is_active BOOLEAN DEFAULT TRUE, - is_visible BOOLEAN DEFAULT TRUE, - - -- Metadata - terms_url TEXT, - risk_disclosure_url TEXT, - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_products_slug ON products(slug); -CREATE INDEX idx_products_type ON products(product_type); -CREATE INDEX idx_products_risk ON products(risk_profile); - --- ============================================================================ --- TABLA: accounts --- Descripción: Cuentas de inversión de usuarios --- ============================================================================ -CREATE TABLE IF NOT EXISTS accounts ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - - -- Referencias - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT, - product_id UUID NOT NULL REFERENCES products(id), - bot_id UUID REFERENCES trading.bots(id), - - -- Identificación - account_number VARCHAR(20) NOT NULL UNIQUE, -- OQ-INV-XXXXXX - name VARCHAR(100), - - -- Moneda - currency CHAR(3) DEFAULT 'USD', - - -- Balances - initial_deposit DECIMAL(15,2) NOT NULL, - current_balance DECIMAL(15,2) NOT NULL, - available_balance DECIMAL(15,2) NOT NULL, -- Balance disponible para retiro - reserved_balance DECIMAL(15,2) DEFAULT 0, -- En operaciones abiertas - - -- Rendimiento acumulado - total_profit DECIMAL(15,2) DEFAULT 0, - total_fees_paid DECIMAL(15,2) DEFAULT 0, - total_deposits DECIMAL(15,2) DEFAULT 0, - total_withdrawals DECIMAL(15,2) DEFAULT 0, - - -- Métricas de rendimiento - total_return_percent DECIMAL(8,4) DEFAULT 0, - monthly_return_percent DECIMAL(8,4) DEFAULT 0, - max_drawdown_percent DECIMAL(5,2) DEFAULT 0, - sharpe_ratio DECIMAL(5,2), - - -- Estado - status account_status_enum DEFAULT 'pending_deposit', - activated_at TIMESTAMPTZ, - paused_at TIMESTAMPTZ, - closed_at TIMESTAMPTZ, - - -- Configuración del usuario - auto_compound BOOLEAN DEFAULT TRUE, -- Reinvertir ganancias - max_drawdown_override DECIMAL(5,2), -- Override del drawdown máximo - pause_on_drawdown BOOLEAN DEFAULT TRUE, -- Pausar si alcanza DD máximo - - -- Aceptación de términos - terms_accepted_at TIMESTAMPTZ, - risk_disclosure_accepted_at TIMESTAMPTZ, - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_accounts_user ON accounts(user_id); -CREATE INDEX idx_accounts_product ON accounts(product_id); -CREATE INDEX idx_accounts_status ON accounts(status); -CREATE INDEX idx_accounts_number ON accounts(account_number); - --- ============================================================================ --- TABLA: account_transactions --- Descripción: Movimientos en cuentas de inversión --- ============================================================================ -CREATE TYPE account_transaction_type AS ENUM ( - 'deposit', - 'withdrawal', - 'profit', - 'loss', - 'fee', - 'adjustment', - 'transfer_in', - 'transfer_out' -); - -CREATE TABLE IF NOT EXISTS account_transactions ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE RESTRICT, - - -- Tipo y monto - transaction_type account_transaction_type NOT NULL, - amount DECIMAL(15,2) NOT NULL, -- Positivo o negativo según tipo - currency CHAR(3) DEFAULT 'USD', - - -- Balances - balance_before DECIMAL(15,2) NOT NULL, - balance_after DECIMAL(15,2) NOT NULL, - - -- Referencia externa - reference_type VARCHAR(50), -- 'wallet_transaction', 'position', 'fee_charge' - reference_id UUID, - - -- Descripción - description TEXT, - - -- Estado - status VARCHAR(20) DEFAULT 'completed', -- 'pending', 'completed', 'cancelled' - processed_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_account_tx_account ON account_transactions(account_id); -CREATE INDEX idx_account_tx_type ON account_transactions(transaction_type); -CREATE INDEX idx_account_tx_created ON account_transactions(created_at DESC); - --- ============================================================================ --- TABLA: performance_snapshots --- Descripción: Snapshots periódicos de rendimiento --- ============================================================================ -CREATE TABLE IF NOT EXISTS performance_snapshots ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, - - -- Período - snapshot_date DATE NOT NULL, - period_type VARCHAR(20) NOT NULL, -- 'daily', 'weekly', 'monthly' - - -- Valores - opening_balance DECIMAL(15,2) NOT NULL, - closing_balance DECIMAL(15,2) NOT NULL, - deposits DECIMAL(15,2) DEFAULT 0, - withdrawals DECIMAL(15,2) DEFAULT 0, - profit_loss DECIMAL(15,2) NOT NULL, - fees DECIMAL(15,2) DEFAULT 0, - - -- Métricas - return_percent DECIMAL(8,4), - drawdown_percent DECIMAL(5,2), - - -- Trading stats - total_trades INT DEFAULT 0, - winning_trades INT DEFAULT 0, - positions_opened INT DEFAULT 0, - positions_closed INT DEFAULT 0, - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - - UNIQUE(account_id, snapshot_date, period_type) -); - -CREATE INDEX idx_perf_snapshots_account ON performance_snapshots(account_id); -CREATE INDEX idx_perf_snapshots_date ON performance_snapshots(snapshot_date DESC); - --- ============================================================================ --- TABLA: profit_distributions --- Descripción: Distribución de utilidades (para cuentas con profit sharing) --- ============================================================================ -CREATE TABLE IF NOT EXISTS profit_distributions ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE RESTRICT, - - -- Período - period_start DATE NOT NULL, - period_end DATE NOT NULL, - - -- Cálculo - gross_profit DECIMAL(15,2) NOT NULL, - management_fee DECIMAL(15,2) DEFAULT 0, - net_profit DECIMAL(15,2) NOT NULL, - - -- Distribución - platform_share_percent DECIMAL(5,2) NOT NULL, - client_share_percent DECIMAL(5,2) NOT NULL, - platform_amount DECIMAL(15,2) NOT NULL, - client_amount DECIMAL(15,2) NOT NULL, - - -- Estado - status VARCHAR(20) DEFAULT 'pending', -- 'pending', 'approved', 'distributed', 'cancelled' - distributed_at TIMESTAMPTZ, - approved_by UUID REFERENCES auth.users(id), - - -- Referencia de pago - payment_reference VARCHAR(100), - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_profit_dist_account ON profit_distributions(account_id); -CREATE INDEX idx_profit_dist_period ON profit_distributions(period_end DESC); -CREATE INDEX idx_profit_dist_status ON profit_distributions(status); - --- ============================================================================ --- TABLA: deposit_requests --- Descripción: Solicitudes de depósito a cuentas de inversión --- ============================================================================ -CREATE TABLE IF NOT EXISTS deposit_requests ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE RESTRICT, - user_id UUID NOT NULL REFERENCES auth.users(id), - - amount DECIMAL(15,2) NOT NULL, - currency CHAR(3) DEFAULT 'USD', - - -- Origen - source_type VARCHAR(50) NOT NULL, -- 'wallet', 'external' - source_wallet_id UUID, -- Referencia a financial.wallets - - -- Estado - status VARCHAR(20) DEFAULT 'pending', -- 'pending', 'approved', 'completed', 'rejected' - processed_at TIMESTAMPTZ, - processed_by UUID REFERENCES auth.users(id), - rejection_reason TEXT, - - -- Transacción resultante - transaction_id UUID REFERENCES account_transactions(id), - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_deposit_requests_account ON deposit_requests(account_id); -CREATE INDEX idx_deposit_requests_user ON deposit_requests(user_id); -CREATE INDEX idx_deposit_requests_status ON deposit_requests(status); - --- ============================================================================ --- TABLA: withdrawal_requests --- Descripción: Solicitudes de retiro de cuentas de inversión --- ============================================================================ -CREATE TABLE IF NOT EXISTS withdrawal_requests ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE RESTRICT, - user_id UUID NOT NULL REFERENCES auth.users(id), - - amount DECIMAL(15,2) NOT NULL, - currency CHAR(3) DEFAULT 'USD', - - -- Destino - destination_type VARCHAR(50) NOT NULL, -- 'wallet', 'bank_transfer' - destination_wallet_id UUID, - - -- Estado - status VARCHAR(20) DEFAULT 'pending', -- 'pending', 'processing', 'completed', 'rejected' - processed_at TIMESTAMPTZ, - processed_by UUID REFERENCES auth.users(id), - rejection_reason TEXT, - - -- Comisiones - fee_amount DECIMAL(10,2) DEFAULT 0, - net_amount DECIMAL(15,2), - - -- Transacción resultante - transaction_id UUID REFERENCES account_transactions(id), - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_withdrawal_requests_account ON withdrawal_requests(account_id); -CREATE INDEX idx_withdrawal_requests_user ON withdrawal_requests(user_id); -CREATE INDEX idx_withdrawal_requests_status ON withdrawal_requests(status); - --- ============================================================================ --- TABLA: bot_assignments --- Descripción: Asignación de bots a cuentas de inversión --- ============================================================================ -CREATE TABLE IF NOT EXISTS bot_assignments ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, - bot_id UUID NOT NULL REFERENCES trading.bots(id), - - -- Estado - is_active BOOLEAN DEFAULT TRUE, - assigned_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - deactivated_at TIMESTAMPTZ, - - -- Razón del cambio - assignment_reason TEXT, - deactivation_reason TEXT, - - -- Usuario que asignó - assigned_by UUID REFERENCES auth.users(id), -- NULL = automático - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_bot_assignments_account ON bot_assignments(account_id); -CREATE INDEX idx_bot_assignments_bot ON bot_assignments(bot_id); -CREATE INDEX idx_bot_assignments_active ON bot_assignments(is_active) WHERE is_active = TRUE; - --- ============================================================================ --- TRIGGERS --- ============================================================================ - -CREATE TRIGGER update_products_updated_at - BEFORE UPDATE ON products - FOR EACH ROW - EXECUTE FUNCTION public.update_updated_at_column(); - -CREATE TRIGGER update_accounts_updated_at - BEFORE UPDATE ON accounts - FOR EACH ROW - EXECUTE FUNCTION public.update_updated_at_column(); - -CREATE TRIGGER update_deposit_requests_updated_at - BEFORE UPDATE ON deposit_requests - FOR EACH ROW - EXECUTE FUNCTION public.update_updated_at_column(); - -CREATE TRIGGER update_withdrawal_requests_updated_at - BEFORE UPDATE ON withdrawal_requests - FOR EACH ROW - EXECUTE FUNCTION public.update_updated_at_column(); - -CREATE TRIGGER update_profit_distributions_updated_at - BEFORE UPDATE ON profit_distributions - FOR EACH ROW - EXECUTE FUNCTION public.update_updated_at_column(); - --- ============================================================================ --- FUNCIÓN: Generar número de cuenta --- ============================================================================ -CREATE OR REPLACE FUNCTION generate_account_number() -RETURNS TRIGGER AS $$ -DECLARE - seq_num INT; -BEGIN - SELECT COALESCE(MAX(CAST(SUBSTRING(account_number FROM 8) AS INT)), 0) + 1 - INTO seq_num - FROM investment.accounts; - - NEW.account_number := 'OQ-INV-' || LPAD(seq_num::TEXT, 6, '0'); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER set_account_number - BEFORE INSERT ON accounts - FOR EACH ROW - WHEN (NEW.account_number IS NULL) - EXECUTE FUNCTION generate_account_number(); diff --git a/apps/database/schemas/05_financial_schema.sql b/apps/database/schemas/05_financial_schema.sql deleted file mode 100644 index deb2b80..0000000 --- a/apps/database/schemas/05_financial_schema.sql +++ /dev/null @@ -1,500 +0,0 @@ --- ============================================================================ --- OrbiQuant IA - Esquema FINANCIAL --- ============================================================================ --- Archivo: 05_financial_schema.sql --- Descripción: Pagos, suscripciones, wallets y transacciones --- Fecha: 2025-12-05 --- ============================================================================ - -SET search_path TO financial; - --- ============================================================================ --- TIPOS ENUMERADOS --- ============================================================================ - -CREATE TYPE subscription_status_enum AS ENUM ( - 'trialing', -- Período de prueba - 'active', -- Activa y pagando - 'past_due', -- Pago vencido - 'cancelled', -- Cancelada - 'unpaid', -- Sin pago - 'paused' -- Pausada -); - -CREATE TYPE payment_status_enum AS ENUM ( - 'pending', - 'processing', - 'succeeded', - 'failed', - 'refunded', - 'cancelled' -); - -CREATE TYPE payment_method_enum AS ENUM ( - 'card', - 'bank_transfer', - 'paypal', - 'crypto', - 'wallet_balance' -); - --- ============================================================================ --- TABLA: subscription_plans --- Descripción: Planes de suscripción disponibles --- ============================================================================ -CREATE TABLE IF NOT EXISTS subscription_plans ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - - -- Identificación - name VARCHAR(50) NOT NULL, - slug VARCHAR(50) NOT NULL UNIQUE, - description TEXT, - - -- Pricing - price_monthly DECIMAL(10,2) NOT NULL, - price_yearly DECIMAL(10,2), - currency CHAR(3) DEFAULT 'USD', - - -- Stripe - stripe_price_id_monthly VARCHAR(100), - stripe_price_id_yearly VARCHAR(100), - stripe_product_id VARCHAR(100), - - -- Features (JSON array) - features JSONB DEFAULT '[]', -- [{name, description, included: boolean}] - - -- Límites - max_watchlists INT, - max_alerts INT, - ml_predictions_access BOOLEAN DEFAULT FALSE, - signals_access BOOLEAN DEFAULT FALSE, - backtesting_access BOOLEAN DEFAULT FALSE, - api_access BOOLEAN DEFAULT FALSE, - priority_support BOOLEAN DEFAULT FALSE, - - -- Educación - courses_access VARCHAR(20) DEFAULT 'none', -- 'none', 'free_only', 'basic', 'all' - - -- Orden de display - sort_order INT DEFAULT 0, - is_featured BOOLEAN DEFAULT FALSE, - - -- Estado - is_active BOOLEAN DEFAULT TRUE, - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_plans_slug ON subscription_plans(slug); - --- ============================================================================ --- TABLA: stripe_customers --- Descripción: Clientes de Stripe vinculados a usuarios --- ============================================================================ -CREATE TABLE IF NOT EXISTS stripe_customers ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - user_id UUID NOT NULL UNIQUE REFERENCES auth.users(id) ON DELETE CASCADE, - - stripe_customer_id VARCHAR(100) NOT NULL UNIQUE, - email VARCHAR(255), - - -- Método de pago por defecto - default_payment_method_id VARCHAR(100), - - -- Metadata - metadata JSONB, - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_stripe_customers_user ON stripe_customers(user_id); -CREATE INDEX idx_stripe_customers_stripe ON stripe_customers(stripe_customer_id); - --- ============================================================================ --- TABLA: subscriptions --- Descripción: Suscripciones activas de usuarios --- ============================================================================ -CREATE TABLE IF NOT EXISTS subscriptions ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT, - plan_id UUID NOT NULL REFERENCES subscription_plans(id), - - -- Stripe - stripe_subscription_id VARCHAR(100) UNIQUE, - stripe_customer_id VARCHAR(100) REFERENCES stripe_customers(stripe_customer_id), - - -- Estado - status subscription_status_enum DEFAULT 'active', - - -- Período de facturación - billing_cycle VARCHAR(20) DEFAULT 'monthly', -- 'monthly', 'yearly' - current_period_start TIMESTAMPTZ, - current_period_end TIMESTAMPTZ, - - -- Trial - trial_start TIMESTAMPTZ, - trial_end TIMESTAMPTZ, - - -- Cancelación - cancel_at_period_end BOOLEAN DEFAULT FALSE, - cancelled_at TIMESTAMPTZ, - cancellation_reason TEXT, - - -- Precio actual (puede diferir del plan por descuentos) - current_price DECIMAL(10,2), - currency CHAR(3) DEFAULT 'USD', - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_subscriptions_user ON subscriptions(user_id); -CREATE INDEX idx_subscriptions_plan ON subscriptions(plan_id); -CREATE INDEX idx_subscriptions_status ON subscriptions(status); -CREATE INDEX idx_subscriptions_stripe ON subscriptions(stripe_subscription_id); - --- ============================================================================ --- TABLA: wallets --- Descripción: Wallets internos de usuarios --- ============================================================================ -CREATE TABLE IF NOT EXISTS wallets ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT, - - -- Balance - currency CHAR(3) NOT NULL DEFAULT 'USD', - balance DECIMAL(15,2) NOT NULL DEFAULT 0, - available_balance DECIMAL(15,2) NOT NULL DEFAULT 0, - pending_balance DECIMAL(15,2) NOT NULL DEFAULT 0, - - -- Estado - is_active BOOLEAN DEFAULT TRUE, - - -- Límites - daily_withdrawal_limit DECIMAL(15,2) DEFAULT 10000, - monthly_withdrawal_limit DECIMAL(15,2) DEFAULT 50000, - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - - UNIQUE(user_id, currency) -); - -CREATE INDEX idx_wallets_user ON wallets(user_id); - --- ============================================================================ --- TABLA: wallet_transactions --- Descripción: Transacciones en wallets --- ============================================================================ -CREATE TABLE IF NOT EXISTS wallet_transactions ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - wallet_id UUID NOT NULL REFERENCES wallets(id) ON DELETE RESTRICT, - user_id UUID NOT NULL REFERENCES auth.users(id), - - -- Tipo y monto - transaction_type public.transaction_type_enum NOT NULL, - amount DECIMAL(15,2) NOT NULL, - currency CHAR(3) DEFAULT 'USD', - - -- Balance - balance_before DECIMAL(15,2) NOT NULL, - balance_after DECIMAL(15,2) NOT NULL, - - -- Referencia - reference_type VARCHAR(50), -- 'payment', 'subscription', 'investment_account', 'refund' - reference_id UUID, - external_reference VARCHAR(100), -- ID externo (Stripe, etc.) - - -- Descripción - description TEXT, - - -- Estado - status payment_status_enum DEFAULT 'succeeded', - - -- Metadata - metadata JSONB, - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_wallet_tx_wallet ON wallet_transactions(wallet_id); -CREATE INDEX idx_wallet_tx_user ON wallet_transactions(user_id); -CREATE INDEX idx_wallet_tx_type ON wallet_transactions(transaction_type); -CREATE INDEX idx_wallet_tx_created ON wallet_transactions(created_at DESC); - --- ============================================================================ --- TABLA: payments --- Descripción: Pagos procesados --- ============================================================================ -CREATE TABLE IF NOT EXISTS payments ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - user_id UUID NOT NULL REFERENCES auth.users(id), - - -- Tipo - payment_type VARCHAR(50) NOT NULL, -- 'subscription', 'course', 'deposit', 'one_time' - - -- Monto - amount DECIMAL(10,2) NOT NULL, - currency CHAR(3) DEFAULT 'USD', - fee DECIMAL(10,2) DEFAULT 0, - net_amount DECIMAL(10,2), - - -- Método - payment_method payment_method_enum, - payment_method_details JSONB, -- Últimos 4 dígitos, tipo de tarjeta, etc. - - -- Stripe - stripe_payment_intent_id VARCHAR(100), - stripe_charge_id VARCHAR(100), - stripe_invoice_id VARCHAR(100), - - -- Estado - status payment_status_enum DEFAULT 'pending', - failure_reason TEXT, - - -- Referencia - reference_type VARCHAR(50), -- 'subscription', 'course', 'wallet_deposit' - reference_id UUID, - - -- Descripción - description TEXT, - - -- Metadata - metadata JSONB, - ip_address INET, - - -- Facturación - invoice_url TEXT, - receipt_url TEXT, - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_payments_user ON payments(user_id); -CREATE INDEX idx_payments_status ON payments(status); -CREATE INDEX idx_payments_type ON payments(payment_type); -CREATE INDEX idx_payments_stripe ON payments(stripe_payment_intent_id); -CREATE INDEX idx_payments_created ON payments(created_at DESC); - --- ============================================================================ --- TABLA: refunds --- Descripción: Reembolsos procesados --- ============================================================================ -CREATE TABLE IF NOT EXISTS refunds ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - payment_id UUID NOT NULL REFERENCES payments(id), - user_id UUID NOT NULL REFERENCES auth.users(id), - - -- Monto - amount DECIMAL(10,2) NOT NULL, - currency CHAR(3) DEFAULT 'USD', - - -- Stripe - stripe_refund_id VARCHAR(100), - - -- Razón - reason VARCHAR(100), - notes TEXT, - - -- Estado - status payment_status_enum DEFAULT 'pending', - - -- Procesado por - processed_by UUID REFERENCES auth.users(id), - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_refunds_payment ON refunds(payment_id); -CREATE INDEX idx_refunds_user ON refunds(user_id); - --- ============================================================================ --- TABLA: invoices --- Descripción: Facturas generadas --- ============================================================================ -CREATE TABLE IF NOT EXISTS invoices ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - user_id UUID NOT NULL REFERENCES auth.users(id), - - -- Número de factura - invoice_number VARCHAR(50) NOT NULL UNIQUE, - - -- Stripe - stripe_invoice_id VARCHAR(100) UNIQUE, - - -- Montos - subtotal DECIMAL(10,2) NOT NULL, - tax DECIMAL(10,2) DEFAULT 0, - total DECIMAL(10,2) NOT NULL, - amount_paid DECIMAL(10,2) DEFAULT 0, - amount_due DECIMAL(10,2), - currency CHAR(3) DEFAULT 'USD', - - -- Estado - status VARCHAR(20) DEFAULT 'draft', -- 'draft', 'open', 'paid', 'void', 'uncollectible' - - -- Fechas - due_date DATE, - paid_at TIMESTAMPTZ, - - -- Items - line_items JSONB DEFAULT '[]', -- [{description, quantity, unit_price, amount}] - - -- PDFs - pdf_url TEXT, - hosted_invoice_url TEXT, - - -- Datos fiscales del cliente - billing_details JSONB, - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_invoices_user ON invoices(user_id); -CREATE INDEX idx_invoices_status ON invoices(status); -CREATE INDEX idx_invoices_number ON invoices(invoice_number); - --- ============================================================================ --- TABLA: payout_requests --- Descripción: Solicitudes de retiro a cuenta bancaria/externa --- ============================================================================ -CREATE TABLE IF NOT EXISTS payout_requests ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - user_id UUID NOT NULL REFERENCES auth.users(id), - wallet_id UUID NOT NULL REFERENCES wallets(id), - - -- Monto - amount DECIMAL(15,2) NOT NULL, - currency CHAR(3) DEFAULT 'USD', - fee DECIMAL(10,2) DEFAULT 0, - net_amount DECIMAL(15,2), - - -- Destino - payout_method VARCHAR(50) NOT NULL, -- 'bank_transfer', 'paypal', 'crypto' - destination_details JSONB, -- Cuenta bancaria, dirección, etc. - - -- Estado - status VARCHAR(20) DEFAULT 'pending', -- 'pending', 'processing', 'completed', 'rejected', 'cancelled' - processed_at TIMESTAMPTZ, - completed_at TIMESTAMPTZ, - - -- Procesamiento - processed_by UUID REFERENCES auth.users(id), - rejection_reason TEXT, - external_reference VARCHAR(100), - - -- Transacción de wallet - wallet_transaction_id UUID REFERENCES wallet_transactions(id), - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_payouts_user ON payout_requests(user_id); -CREATE INDEX idx_payouts_wallet ON payout_requests(wallet_id); -CREATE INDEX idx_payouts_status ON payout_requests(status); - --- ============================================================================ --- TABLA: promo_codes --- Descripción: Códigos promocionales y descuentos --- ============================================================================ -CREATE TABLE IF NOT EXISTS promo_codes ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - - code VARCHAR(50) NOT NULL UNIQUE, - description TEXT, - - -- Tipo de descuento - discount_type VARCHAR(20) NOT NULL, -- 'percentage', 'fixed_amount' - discount_value DECIMAL(10,2) NOT NULL, - currency CHAR(3) DEFAULT 'USD', - - -- Aplicabilidad - applies_to VARCHAR(50) DEFAULT 'all', -- 'all', 'subscription', 'course' - applicable_plan_ids UUID[], - applicable_course_ids UUID[], - - -- Límites - max_uses INT, - current_uses INT DEFAULT 0, - max_uses_per_user INT DEFAULT 1, - - -- Validez - valid_from TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - valid_until TIMESTAMPTZ, - - -- Requisitos - min_purchase_amount DECIMAL(10,2), - first_time_only BOOLEAN DEFAULT FALSE, - - -- Estado - is_active BOOLEAN DEFAULT TRUE, - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_promo_codes_code ON promo_codes(code); -CREATE INDEX idx_promo_codes_active ON promo_codes(is_active) WHERE is_active = TRUE; - --- ============================================================================ --- TABLA: promo_code_uses --- Descripción: Uso de códigos promocionales --- ============================================================================ -CREATE TABLE IF NOT EXISTS promo_code_uses ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - promo_code_id UUID NOT NULL REFERENCES promo_codes(id), - user_id UUID NOT NULL REFERENCES auth.users(id), - payment_id UUID REFERENCES payments(id), - - discount_applied DECIMAL(10,2) NOT NULL, - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_promo_uses_code ON promo_code_uses(promo_code_id); -CREATE INDEX idx_promo_uses_user ON promo_code_uses(user_id); - --- ============================================================================ --- TRIGGERS --- ============================================================================ - -CREATE TRIGGER update_plans_updated_at - BEFORE UPDATE ON subscription_plans - FOR EACH ROW - EXECUTE FUNCTION public.update_updated_at_column(); - -CREATE TRIGGER update_stripe_customers_updated_at - BEFORE UPDATE ON stripe_customers - FOR EACH ROW - EXECUTE FUNCTION public.update_updated_at_column(); - -CREATE TRIGGER update_subscriptions_updated_at - BEFORE UPDATE ON subscriptions - FOR EACH ROW - EXECUTE FUNCTION public.update_updated_at_column(); - -CREATE TRIGGER update_wallets_updated_at - BEFORE UPDATE ON wallets - FOR EACH ROW - EXECUTE FUNCTION public.update_updated_at_column(); - -CREATE TRIGGER update_payments_updated_at - BEFORE UPDATE ON payments - FOR EACH ROW - EXECUTE FUNCTION public.update_updated_at_column(); - -CREATE TRIGGER update_invoices_updated_at - BEFORE UPDATE ON invoices - FOR EACH ROW - EXECUTE FUNCTION public.update_updated_at_column(); - -CREATE TRIGGER update_payouts_updated_at - BEFORE UPDATE ON payout_requests - FOR EACH ROW - EXECUTE FUNCTION public.update_updated_at_column(); diff --git a/apps/database/schemas/06_ml_schema.sql b/apps/database/schemas/06_ml_schema.sql deleted file mode 100644 index 935adce..0000000 --- a/apps/database/schemas/06_ml_schema.sql +++ /dev/null @@ -1,426 +0,0 @@ --- ============================================================================ --- OrbiQuant IA - Esquema ML (Machine Learning) --- ============================================================================ --- Archivo: 06_ml_schema.sql --- Descripción: Modelos ML, predicciones, features y métricas --- Fecha: 2025-12-05 --- ============================================================================ - -SET search_path TO ml; - --- ============================================================================ --- TIPOS ENUMERADOS --- ============================================================================ - -CREATE TYPE model_type_enum AS ENUM ( - 'range_predictor', -- Predicción de rangos (ΔHigh/ΔLow) - 'tpsl_classifier', -- Clasificación TP vs SL - 'signal_generator', -- Generador de señales - 'regime_classifier', -- Clasificación de régimen de mercado - 'amd_detector', -- Detector de fases AMD - 'volatility_model', -- Modelo de volatilidad - 'ensemble' -- Meta-modelo ensemble -); - -CREATE TYPE model_status_enum AS ENUM ( - 'training', - 'validating', - 'ready', - 'deployed', - 'deprecated', - 'failed' -); - --- ============================================================================ --- TABLA: models --- Descripción: Registro de modelos ML --- ============================================================================ -CREATE TABLE IF NOT EXISTS models ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - - -- Identificación - name VARCHAR(100) NOT NULL, - slug VARCHAR(100) NOT NULL, - description TEXT, - - -- Tipo y versión - model_type model_type_enum NOT NULL, - version VARCHAR(20) NOT NULL, - is_latest BOOLEAN DEFAULT FALSE, - - -- Símbolos y timeframes - symbols TEXT[], -- NULL = todos - timeframes trading.timeframe_enum[], - - -- Arquitectura - algorithm VARCHAR(50), -- 'xgboost', 'gru', 'transformer', 'ensemble' - architecture_config JSONB, -- Configuración de arquitectura - - -- Hiperparámetros - hyperparameters JSONB, - - -- Features - feature_columns TEXT[], - feature_count INT, - - -- Artifact - artifact_path TEXT, -- Path al modelo serializado - artifact_size_mb DECIMAL(10,2), - - -- Estado - status model_status_enum DEFAULT 'training', - deployed_at TIMESTAMPTZ, - - -- Metadata - training_duration_seconds INT, - total_samples INT, - created_by UUID REFERENCES public.users(id), - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - - UNIQUE(slug, version) -); - -CREATE INDEX idx_models_slug ON models(slug); -CREATE INDEX idx_models_type ON models(model_type); -CREATE INDEX idx_models_status ON models(status); -CREATE INDEX idx_models_latest ON models(is_latest) WHERE is_latest = TRUE; - --- ============================================================================ --- TABLA: training_runs --- Descripción: Ejecuciones de entrenamiento --- ============================================================================ -CREATE TABLE IF NOT EXISTS training_runs ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - model_id UUID REFERENCES models(id) ON DELETE SET NULL, - - -- Configuración - run_name VARCHAR(100), - config JSONB NOT NULL, - - -- Datos - training_data_start DATE, - training_data_end DATE, - validation_data_start DATE, - validation_data_end DATE, - total_samples INT, - training_samples INT, - validation_samples INT, - - -- Walk-forward - walk_forward_splits INT, - walk_forward_config JSONB, - - -- Estado - status VARCHAR(20) DEFAULT 'running', -- 'running', 'completed', 'failed', 'cancelled' - started_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - completed_at TIMESTAMPTZ, - error_message TEXT, - - -- Recursos - gpu_used BOOLEAN DEFAULT FALSE, - memory_peak_mb INT, - duration_seconds INT, - - -- Logs - logs_path TEXT, - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_training_runs_model ON training_runs(model_id); -CREATE INDEX idx_training_runs_status ON training_runs(status); - --- ============================================================================ --- TABLA: model_metrics --- Descripción: Métricas de rendimiento de modelos --- ============================================================================ -CREATE TABLE IF NOT EXISTS model_metrics ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - model_id UUID NOT NULL REFERENCES models(id) ON DELETE CASCADE, - training_run_id UUID REFERENCES training_runs(id), - - -- Tipo de métricas - metric_set VARCHAR(50) NOT NULL, -- 'training', 'validation', 'test', 'production' - split_index INT, -- Para walk-forward - - -- Métricas de regresión - mae DECIMAL(10,6), - rmse DECIMAL(10,6), - mape DECIMAL(10,6), - r2_score DECIMAL(10,6), - - -- Métricas de clasificación - accuracy DECIMAL(5,4), - precision_score DECIMAL(5,4), - recall_score DECIMAL(5,4), - f1_score DECIMAL(5,4), - roc_auc DECIMAL(5,4), - - -- Métricas por clase - confusion_matrix JSONB, - classification_report JSONB, - - -- Métricas de trading - win_rate DECIMAL(5,4), - profit_factor DECIMAL(6,2), - sharpe_ratio DECIMAL(6,2), - sortino_ratio DECIMAL(6,2), - max_drawdown DECIMAL(5,4), - - -- Feature importance - feature_importance JSONB, - - -- Timestamp - calculated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_model_metrics_model ON model_metrics(model_id); -CREATE INDEX idx_model_metrics_set ON model_metrics(metric_set); - --- ============================================================================ --- TABLA: predictions --- Descripción: Predicciones generadas --- ============================================================================ -CREATE TABLE IF NOT EXISTS predictions ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - model_id UUID NOT NULL REFERENCES models(id), - - -- Contexto - symbol VARCHAR(20) NOT NULL, - timeframe trading.timeframe_enum NOT NULL, - prediction_timestamp TIMESTAMPTZ NOT NULL, - - -- Input - candle_timestamp TIMESTAMPTZ NOT NULL, -- Timestamp de la vela de entrada - input_features JSONB, -- Features usadas (opcional, para debugging) - - -- Predicción de rango - predicted_delta_high DECIMAL(20,8), - predicted_delta_low DECIMAL(20,8), - predicted_delta_high_1h DECIMAL(20,8), - predicted_delta_low_1h DECIMAL(20,8), - - -- Clasificación de bins (ATR-based) - predicted_high_bin INT, - predicted_low_bin INT, - bin_probabilities JSONB, - - -- TP/SL prediction - prob_tp_first DECIMAL(5,4), - rr_config VARCHAR(20), -- 'rr_2_1', 'rr_3_1' - - -- Confianza - confidence_score DECIMAL(5,4), - model_uncertainty DECIMAL(5,4), - - -- Contexto de mercado predicho - predicted_amd_phase trading.amd_phase_enum, - predicted_volatility trading.volatility_regime_enum, - - -- Resultado real (llenado posteriormente) - actual_delta_high DECIMAL(20,8), - actual_delta_low DECIMAL(20,8), - actual_tp_sl_outcome VARCHAR(20), -- 'tp_hit', 'sl_hit', 'neither' - outcome_recorded_at TIMESTAMPTZ, - - -- Error calculado - error_high DECIMAL(20,8), - error_low DECIMAL(20,8), - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_predictions_model ON predictions(model_id); -CREATE INDEX idx_predictions_symbol ON predictions(symbol); -CREATE INDEX idx_predictions_timestamp ON predictions(prediction_timestamp DESC); -CREATE INDEX idx_predictions_candle ON predictions(candle_timestamp); - --- Particionamiento por fecha para mejor rendimiento --- (En producción, considerar particionar por mes) - --- ============================================================================ --- TABLA: prediction_accuracy_daily --- Descripción: Precisión de predicciones agregada por día --- ============================================================================ -CREATE TABLE IF NOT EXISTS prediction_accuracy_daily ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - model_id UUID NOT NULL REFERENCES models(id) ON DELETE CASCADE, - symbol VARCHAR(20) NOT NULL, - date DATE NOT NULL, - - -- Conteos - total_predictions INT DEFAULT 0, - predictions_evaluated INT DEFAULT 0, - - -- Métricas de rango - mae_high DECIMAL(10,6), - mae_low DECIMAL(10,6), - mape_high DECIMAL(10,6), - mape_low DECIMAL(10,6), - - -- Métricas de TP/SL - tp_sl_predictions INT DEFAULT 0, - tp_correct INT DEFAULT 0, - sl_correct INT DEFAULT 0, - accuracy_tp_sl DECIMAL(5,4), - - -- Métricas de bins - bin_accuracy DECIMAL(5,4), - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - - UNIQUE(model_id, symbol, date) -); - -CREATE INDEX idx_pred_accuracy_model ON prediction_accuracy_daily(model_id); -CREATE INDEX idx_pred_accuracy_date ON prediction_accuracy_daily(date DESC); - --- ============================================================================ --- TABLA: feature_store --- Descripción: Features pre-calculadas para inferencia rápida --- ============================================================================ -CREATE TABLE IF NOT EXISTS feature_store ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - - symbol VARCHAR(20) NOT NULL, - timeframe trading.timeframe_enum NOT NULL, - candle_timestamp TIMESTAMPTZ NOT NULL, - - -- OHLCV base - open DECIMAL(20,8) NOT NULL, - high DECIMAL(20,8) NOT NULL, - low DECIMAL(20,8) NOT NULL, - close DECIMAL(20,8) NOT NULL, - volume DECIMAL(20,4), - - -- Features calculadas (las 21 del modelo) - features JSONB NOT NULL, - - -- Indicadores técnicos - indicators JSONB, - - -- Validación - is_valid BOOLEAN DEFAULT TRUE, - validation_errors TEXT[], - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - - UNIQUE(symbol, timeframe, candle_timestamp) -); - -CREATE INDEX idx_feature_store_symbol ON feature_store(symbol, timeframe); -CREATE INDEX idx_feature_store_timestamp ON feature_store(candle_timestamp DESC); - --- ============================================================================ --- TABLA: model_drift_alerts --- Descripción: Alertas de drift de modelo --- ============================================================================ -CREATE TABLE IF NOT EXISTS model_drift_alerts ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - model_id UUID NOT NULL REFERENCES models(id), - - -- Tipo de drift - drift_type VARCHAR(50) NOT NULL, -- 'feature_drift', 'prediction_drift', 'performance_drift' - - -- Detalles - metric_name VARCHAR(100), - expected_value DECIMAL(10,6), - actual_value DECIMAL(10,6), - deviation_percent DECIMAL(10,4), - - -- Severidad - severity VARCHAR(20), -- 'low', 'medium', 'high', 'critical' - - -- Estado - status VARCHAR(20) DEFAULT 'active', -- 'active', 'acknowledged', 'resolved' - acknowledged_by UUID REFERENCES public.users(id), - acknowledged_at TIMESTAMPTZ, - resolved_at TIMESTAMPTZ, - - -- Acción tomada - action_taken TEXT, - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_drift_alerts_model ON model_drift_alerts(model_id); -CREATE INDEX idx_drift_alerts_status ON model_drift_alerts(status); - --- ============================================================================ --- TABLA: ab_tests --- Descripción: Tests A/B de modelos --- ============================================================================ -CREATE TABLE IF NOT EXISTS ab_tests ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - - name VARCHAR(100) NOT NULL, - description TEXT, - - -- Modelos - control_model_id UUID NOT NULL REFERENCES models(id), - treatment_model_id UUID NOT NULL REFERENCES models(id), - - -- Configuración - traffic_split DECIMAL(3,2) DEFAULT 0.50, -- % al tratamiento - target_metric VARCHAR(50), -- Métrica principal a optimizar - - -- Estado - status VARCHAR(20) DEFAULT 'draft', -- 'draft', 'running', 'paused', 'completed', 'cancelled' - started_at TIMESTAMPTZ, - ended_at TIMESTAMPTZ, - - -- Resultados - control_samples INT DEFAULT 0, - treatment_samples INT DEFAULT 0, - control_metric_value DECIMAL(10,6), - treatment_metric_value DECIMAL(10,6), - statistical_significance DECIMAL(5,4), - winner VARCHAR(20), -- 'control', 'treatment', 'inconclusive' - - created_by UUID REFERENCES public.users(id), - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_ab_tests_status ON ab_tests(status); - --- ============================================================================ --- TRIGGERS --- ============================================================================ - -CREATE TRIGGER update_models_updated_at - BEFORE UPDATE ON models - FOR EACH ROW - EXECUTE FUNCTION public.update_updated_at_column(); - -CREATE TRIGGER update_ab_tests_updated_at - BEFORE UPDATE ON ab_tests - FOR EACH ROW - EXECUTE FUNCTION public.update_updated_at_column(); - --- ============================================================================ --- FUNCIÓN: Marcar modelo como latest --- ============================================================================ -CREATE OR REPLACE FUNCTION set_model_as_latest() -RETURNS TRIGGER AS $$ -BEGIN - IF NEW.is_latest = TRUE THEN - UPDATE ml.models - SET is_latest = FALSE - WHERE slug = NEW.slug - AND id != NEW.id - AND is_latest = TRUE; - END IF; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER ensure_single_latest_model - AFTER INSERT OR UPDATE ON models - FOR EACH ROW - WHEN (NEW.is_latest = TRUE) - EXECUTE FUNCTION set_model_as_latest(); diff --git a/apps/database/schemas/07_audit_schema.sql b/apps/database/schemas/07_audit_schema.sql deleted file mode 100644 index ada98e7..0000000 --- a/apps/database/schemas/07_audit_schema.sql +++ /dev/null @@ -1,402 +0,0 @@ --- ============================================================================ --- OrbiQuant IA - Esquema AUDIT --- ============================================================================ --- Archivo: 07_audit_schema.sql --- Descripción: Logs de auditoría, eventos del sistema y seguridad --- Fecha: 2025-12-05 --- ============================================================================ - -SET search_path TO audit; - --- ============================================================================ --- TABLA: audit_logs --- Descripción: Log general de acciones en el sistema --- ============================================================================ -CREATE TABLE IF NOT EXISTS audit_logs ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - - -- Tabla afectada - table_name VARCHAR(100) NOT NULL, - record_id TEXT, - - -- Acción - action VARCHAR(20) NOT NULL, -- 'INSERT', 'UPDATE', 'DELETE' - - -- Datos - old_data JSONB, - new_data JSONB, - - -- Usuario - user_id UUID, - user_email VARCHAR(255), - - -- Contexto - ip_address INET, - user_agent TEXT, - session_id UUID, - - -- Timestamp - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - --- Índices para búsqueda eficiente -CREATE INDEX idx_audit_logs_table ON audit_logs(table_name); -CREATE INDEX idx_audit_logs_record ON audit_logs(table_name, record_id); -CREATE INDEX idx_audit_logs_user ON audit_logs(user_id); -CREATE INDEX idx_audit_logs_action ON audit_logs(action); -CREATE INDEX idx_audit_logs_created ON audit_logs(created_at DESC); - --- Particionamiento por mes (para producción) --- CREATE TABLE audit_logs_2025_01 PARTITION OF audit_logs --- FOR VALUES FROM ('2025-01-01') TO ('2025-02-01'); - --- ============================================================================ --- TABLA: security_events --- Descripción: Eventos de seguridad --- ============================================================================ -CREATE TYPE security_event_type AS ENUM ( - 'login_success', - 'login_failed', - 'logout', - 'password_changed', - 'password_reset_requested', - 'password_reset_completed', - '2fa_enabled', - '2fa_disabled', - '2fa_failed', - 'account_locked', - 'account_unlocked', - 'suspicious_activity', - 'api_key_created', - 'api_key_revoked', - 'permission_denied', - 'rate_limit_exceeded' -); - -CREATE TYPE security_severity AS ENUM ('info', 'warning', 'error', 'critical'); - -CREATE TABLE IF NOT EXISTS security_events ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - - -- Usuario - user_id UUID REFERENCES auth.users(id), - user_email VARCHAR(255), - - -- Evento - event_type security_event_type NOT NULL, - severity security_severity DEFAULT 'info', - description TEXT, - - -- Contexto - ip_address INET, - user_agent TEXT, - location JSONB, -- {country, city, lat, lon} - - -- Datos adicionales - metadata JSONB, - - -- Estado - acknowledged BOOLEAN DEFAULT FALSE, - acknowledged_by UUID REFERENCES auth.users(id), - acknowledged_at TIMESTAMPTZ, - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_security_events_user ON security_events(user_id); -CREATE INDEX idx_security_events_type ON security_events(event_type); -CREATE INDEX idx_security_events_severity ON security_events(severity); -CREATE INDEX idx_security_events_created ON security_events(created_at DESC); -CREATE INDEX idx_security_events_unack ON security_events(acknowledged) WHERE acknowledged = FALSE; - --- ============================================================================ --- TABLA: system_events --- Descripción: Eventos del sistema (no de usuario) --- ============================================================================ -CREATE TYPE system_event_type AS ENUM ( - 'service_started', - 'service_stopped', - 'service_error', - 'database_backup', - 'database_restore', - 'deployment', - 'config_changed', - 'scheduled_job_started', - 'scheduled_job_completed', - 'scheduled_job_failed', - 'integration_connected', - 'integration_disconnected', - 'integration_error', - 'alert_triggered', - 'maintenance_started', - 'maintenance_completed' -); - -CREATE TABLE IF NOT EXISTS system_events ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - - -- Evento - event_type system_event_type NOT NULL, - service_name VARCHAR(100), - description TEXT, - - -- Severidad - severity security_severity DEFAULT 'info', - - -- Detalles - details JSONB, - error_message TEXT, - stack_trace TEXT, - - -- Metadata - hostname VARCHAR(255), - environment VARCHAR(50), -- 'development', 'staging', 'production' - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_system_events_type ON system_events(event_type); -CREATE INDEX idx_system_events_service ON system_events(service_name); -CREATE INDEX idx_system_events_severity ON system_events(severity); -CREATE INDEX idx_system_events_created ON system_events(created_at DESC); - --- ============================================================================ --- TABLA: trading_audit --- Descripción: Auditoría específica de operaciones de trading --- ============================================================================ -CREATE TABLE IF NOT EXISTS trading_audit ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - - -- Referencias - bot_id UUID, - signal_id UUID, - position_id UUID, - account_id UUID, - user_id UUID, - - -- Evento - event_type VARCHAR(50) NOT NULL, -- 'signal_generated', 'order_placed', 'order_filled', etc. - description TEXT, - - -- Datos de la operación - symbol VARCHAR(20), - direction VARCHAR(10), - lot_size DECIMAL(10,4), - price DECIMAL(20,8), - - -- Precios - entry_price DECIMAL(20,8), - stop_loss DECIMAL(20,8), - take_profit DECIMAL(20,8), - - -- Resultado - pnl DECIMAL(15,2), - outcome VARCHAR(20), - - -- Contexto del modelo - model_id UUID, - confidence DECIMAL(5,4), - amd_phase VARCHAR(20), - - -- Metadata - metadata JSONB, - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_trading_audit_bot ON trading_audit(bot_id); -CREATE INDEX idx_trading_audit_signal ON trading_audit(signal_id); -CREATE INDEX idx_trading_audit_position ON trading_audit(position_id); -CREATE INDEX idx_trading_audit_account ON trading_audit(account_id); -CREATE INDEX idx_trading_audit_event ON trading_audit(event_type); -CREATE INDEX idx_trading_audit_symbol ON trading_audit(symbol); -CREATE INDEX idx_trading_audit_created ON trading_audit(created_at DESC); - --- ============================================================================ --- TABLA: api_request_logs --- Descripción: Logs de requests a la API --- ============================================================================ -CREATE TABLE IF NOT EXISTS api_request_logs ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - - -- Usuario - user_id UUID, - api_key_id UUID, - - -- Request - method VARCHAR(10) NOT NULL, - path VARCHAR(500) NOT NULL, - query_params JSONB, - headers JSONB, - body_size INT, - - -- Response - status_code INT, - response_time_ms INT, - response_size INT, - - -- Contexto - ip_address INET, - user_agent TEXT, - - -- Error (si aplica) - error_message TEXT, - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - --- Índices para análisis -CREATE INDEX idx_api_logs_user ON api_request_logs(user_id); -CREATE INDEX idx_api_logs_path ON api_request_logs(path); -CREATE INDEX idx_api_logs_status ON api_request_logs(status_code); -CREATE INDEX idx_api_logs_created ON api_request_logs(created_at DESC); - --- ============================================================================ --- TABLA: data_access_logs --- Descripción: Log de acceso a datos sensibles --- ============================================================================ -CREATE TABLE IF NOT EXISTS data_access_logs ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - - -- Usuario que accedió - user_id UUID NOT NULL REFERENCES auth.users(id), - - -- Dato accedido - resource_type VARCHAR(100) NOT NULL, -- 'user_profile', 'kyc_document', 'wallet_balance', etc. - resource_id UUID, - resource_owner_id UUID, -- Usuario dueño del dato - - -- Acción - action VARCHAR(50) NOT NULL, -- 'view', 'export', 'modify' - - -- Contexto - reason TEXT, -- Justificación del acceso - ip_address INET, - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_data_access_user ON data_access_logs(user_id); -CREATE INDEX idx_data_access_resource ON data_access_logs(resource_type, resource_id); -CREATE INDEX idx_data_access_owner ON data_access_logs(resource_owner_id); -CREATE INDEX idx_data_access_created ON data_access_logs(created_at DESC); - --- ============================================================================ --- TABLA: compliance_logs --- Descripción: Logs de cumplimiento regulatorio --- ============================================================================ -CREATE TABLE IF NOT EXISTS compliance_logs ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - - -- Usuario - user_id UUID REFERENCES auth.users(id), - - -- Evento - event_type VARCHAR(100) NOT NULL, - -- 'terms_accepted', 'risk_disclosure_accepted', 'kyc_submitted', 'kyc_approved', - -- 'aml_check_passed', 'suspicious_activity_flagged', etc. - - -- Detalles - description TEXT, - document_version VARCHAR(50), - document_url TEXT, - - -- Metadata - metadata JSONB, - ip_address INET, - - -- Estado de revisión - requires_review BOOLEAN DEFAULT FALSE, - reviewed_by UUID REFERENCES auth.users(id), - reviewed_at TIMESTAMPTZ, - review_notes TEXT, - - created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_compliance_user ON compliance_logs(user_id); -CREATE INDEX idx_compliance_event ON compliance_logs(event_type); -CREATE INDEX idx_compliance_review ON compliance_logs(requires_review) WHERE requires_review = TRUE; -CREATE INDEX idx_compliance_created ON compliance_logs(created_at DESC); - --- ============================================================================ --- VISTAS DE ADMINISTRACIÓN --- ============================================================================ - --- Vista: Actividad reciente de usuarios -CREATE OR REPLACE VIEW admin_user_activity AS -SELECT - u.id AS user_id, - u.email, - p.first_name, - p.last_name, - u.role, - u.status, - u.last_login_at, - u.last_login_ip, - (SELECT COUNT(*) FROM audit.security_events se WHERE se.user_id = u.id AND se.created_at > NOW() - INTERVAL '24 hours') AS security_events_24h, - (SELECT COUNT(*) FROM audit.api_request_logs ar WHERE ar.user_id = u.id AND ar.created_at > NOW() - INTERVAL '24 hours') AS api_requests_24h -FROM auth.users u -LEFT JOIN public.profiles p ON p.user_id = u.id; - --- Vista: Alertas de seguridad pendientes -CREATE OR REPLACE VIEW admin_security_alerts AS -SELECT - se.*, - u.email AS user_email, - p.first_name, - p.last_name -FROM audit.security_events se -LEFT JOIN auth.users u ON u.id = se.user_id -LEFT JOIN public.profiles p ON p.user_id = se.user_id -WHERE se.acknowledged = FALSE - AND se.severity IN ('warning', 'error', 'critical') -ORDER BY se.created_at DESC; - --- Vista: Resumen de trading por bot -CREATE OR REPLACE VIEW admin_bot_trading_summary AS -SELECT - ta.bot_id, - b.name AS bot_name, - b.risk_profile, - DATE(ta.created_at) AS date, - COUNT(*) FILTER (WHERE ta.event_type = 'signal_generated') AS signals_generated, - COUNT(*) FILTER (WHERE ta.event_type = 'order_placed') AS orders_placed, - COUNT(*) FILTER (WHERE ta.event_type = 'order_filled') AS orders_filled, - SUM(ta.pnl) AS total_pnl, - COUNT(*) FILTER (WHERE ta.pnl > 0) AS winning_trades, - COUNT(*) FILTER (WHERE ta.pnl < 0) AS losing_trades -FROM audit.trading_audit ta -LEFT JOIN trading.bots b ON b.id = ta.bot_id -WHERE ta.bot_id IS NOT NULL -GROUP BY ta.bot_id, b.name, b.risk_profile, DATE(ta.created_at); - --- ============================================================================ --- FUNCIONES DE LIMPIEZA --- ============================================================================ - --- Función para limpiar logs antiguos -CREATE OR REPLACE FUNCTION cleanup_old_logs(retention_days INT DEFAULT 90) -RETURNS TABLE( - table_name TEXT, - rows_deleted BIGINT -) AS $$ -DECLARE - cutoff_date TIMESTAMPTZ; -BEGIN - cutoff_date := NOW() - (retention_days || ' days')::INTERVAL; - - -- API request logs (30 días por defecto) - DELETE FROM audit.api_request_logs WHERE created_at < cutoff_date; - RETURN QUERY SELECT 'api_request_logs'::TEXT, COUNT(*)::BIGINT FROM audit.api_request_logs WHERE created_at < cutoff_date; - - -- Audit logs (90 días por defecto, excepto críticos) - DELETE FROM audit.audit_logs WHERE created_at < cutoff_date; - - -- Security events (mantener indefinidamente los críticos) - DELETE FROM audit.security_events - WHERE created_at < cutoff_date - AND severity NOT IN ('error', 'critical'); - -END; -$$ LANGUAGE plpgsql; diff --git a/apps/database/schemas/_MAP.md b/apps/database/schemas/_MAP.md deleted file mode 100644 index e30e99b..0000000 --- a/apps/database/schemas/_MAP.md +++ /dev/null @@ -1,283 +0,0 @@ -# _MAP - Database Schemas - -> Índice de navegación para los esquemas de base de datos -> -> **ACTUALIZADO:** 2025-12-06 - Estructura DDL completada según análisis de requisitos -> **Política:** Carga Limpia (DDL-First) - Ver DIRECTIVA-POLITICA-CARGA-LIMPIA.md -> **Total:** 63 tablas, 91 archivos SQL - -## Estructura de Archivos - -``` -apps/database/ -├── ddl/ -│ └── schemas/ -│ ├── auth/ # 10 tablas - Autenticación y usuarios -│ ├── education/ # 14 tablas - Plataforma educativa -│ ├── trading/ # 9 tablas - Trading y Paper Engine -│ ├── investment/ # 5 tablas - Cuentas PAMM -│ ├── financial/ # 8 tablas - Pagos, wallets unificadas -│ ├── ml/ # 5 tablas - Machine Learning -│ ├── llm/ # 5 tablas - LLM Agent -│ └── audit/ # 7 tablas - Auditoría y compliance -├── seeds/ -│ ├── prod/ # Seeds de producción -│ └── dev/ # Seeds de desarrollo -└── scripts/ - ├── create-database.sh - └── drop-and-recreate-database.sh -``` - -## Resumen de Schemas - -| Schema | Propósito | Tablas | Funciones | Estado | -|--------|-----------|--------|-----------|--------| -| auth | Autenticación y usuarios | 10 | 4 | ✅ Completado | -| education | Plataforma educativa | 14 | 8 | ✅ Completado | -| trading | Trading y Paper Engine | 9 | 2 | ✅ Completado | -| investment | Cuentas PAMM | 5 | - | ✅ Completado | -| financial | Wallets unificadas, pagos | 8 | 4 | ✅ Completado | -| ml | Machine Learning signals | 5 | - | ✅ Completado | -| llm | LLM Agent, RAG | 5 | - | ✅ Completado | -| audit | Logs, compliance | 7 | - | ✅ Completado | - ---- - -## Detalle por Schema - -### auth (10 tablas) - -| Tabla | Archivo | Descripción | -|-------|---------|-------------| -| `users` | 01-users.sql | Usuarios base del sistema | -| `user_profiles` | 02-user_profiles.sql | Información extendida | -| `oauth_accounts` | 03-oauth_accounts.sql | Cuentas OAuth (Google, GitHub) | -| `sessions` | 04-sessions.sql | Sesiones activas | -| `email_verifications` | 05-email_verifications.sql | Verificación de email | -| `phone_verifications` | 06-phone_verifications.sql | Verificación de teléfono | -| `password_reset_tokens` | 07-password_reset_tokens.sql | Tokens de reset | -| `auth_logs` | 08-auth_logs.sql | Log de autenticación | -| `login_attempts` | 09-login_attempts.sql | **NUEVO** - Control de intentos | -| `rate_limiting_config` | 10-rate_limiting_config.sql | **NUEVO** - Configuración rate limit | - -**Funciones:** update_updated_at, log_auth_event, cleanup_expired_sessions, create_user_profile_trigger - -### education (14 tablas) - -| Tabla | Archivo | Descripción | -|-------|---------|-------------| -| `categories` | 01-categories.sql | Categorías de cursos | -| `courses` | 02-courses.sql | Cursos de trading | -| `modules` | 03-modules.sql | Módulos de cursos | -| `lessons` | 04-lessons.sql | Lecciones individuales | -| `enrollments` | 05-enrollments.sql | Inscripciones | -| `progress` | 06-progress.sql | Progreso por lección | -| `quizzes` | 07-quizzes.sql | Cuestionarios | -| `quiz_questions` | 08-quiz_questions.sql | Preguntas de quiz | -| `quiz_attempts` | 09-quiz_attempts.sql | Intentos de quiz | -| `certificates` | 10-certificates.sql | Certificados emitidos | -| `user_achievements` | 11-user_achievements.sql | Logros de usuarios | -| `user_gamification_profile` | 12-user_gamification_profile.sql | **NUEVO** - Perfil gamificación | -| `user_activity_log` | 13-user_activity_log.sql | **NUEVO** - Log de actividad | -| `course_reviews` | 14-course_reviews.sql | **NUEVO** - Reseñas de cursos | - -**Funciones:** update_updated_at, update_enrollment_progress, auto_complete_enrollment, generate_certificate, update_course_stats, update_enrollment_count, update_gamification_profile, views - -### trading (9 tablas) - -| Tabla | Archivo | Descripción | -|-------|---------|-------------| -| `symbols` | 01-symbols.sql | Instrumentos financieros | -| `watchlists` | 02-watchlists.sql | Listas de seguimiento | -| `watchlist_items` | 03-watchlist_items.sql | Items en watchlists | -| `bots` | 04-bots.sql | Agentes de trading (Atlas, Orion, Nova) | -| `orders` | 05-orders.sql | Órdenes de trading | -| `positions` | 06-positions.sql | Posiciones abiertas/cerradas | -| `trades` | 07-trades.sql | Historial de trades | -| `signals` | 08-signals.sql | **INTERFAZ ML** - Señales generadas | -| `trading_metrics` | 09-trading_metrics.sql | Métricas de rendimiento | - -**Funciones:** calculate_position_pnl, update_bot_stats - -> **Nota:** La tabla `signals` sirve como interfaz entre Trading (consume) y ML (produce), resolviendo la dependencia circular. - -### investment (5 tablas) - -| Tabla | Archivo | Descripción | -|-------|---------|-------------| -| `products` | 01-products.sql | Productos PAMM | -| `accounts` | 02-accounts.sql | Cuentas de inversión | -| `transactions` | 03-transactions.sql | Movimientos de cuenta | -| `distributions` | 04-distributions.sql | Distribución de ganancias (80/20) | -| `risk_questionnaire` | 05-risk_questionnaire.sql | **NUEVO** - Cuestionario de riesgo | - -> **Delimitación:** Este schema solo maneja PAMM. Portfolio personal se gestiona desde dashboard (OQI-008). - -### financial (8 tablas) - -| Tabla | Archivo | Descripción | -|-------|---------|-------------| -| `wallets` | 01-wallets.sql | **UNIFICADO** - Wallets del sistema | -| `wallet_transactions` | 02-wallet_transactions.sql | Transacciones de wallet | -| `subscriptions` | 03-subscriptions.sql | Suscripciones activas | -| `payments` | 04-payments.sql | Pagos procesados (Stripe) | -| `invoices` | 05-invoices.sql | Facturas | -| `wallet_audit_log` | 06-wallet_audit_log.sql | **NUEVO** - Auditoría de wallets | -| `currency_exchange_rates` | 07-currency_exchange_rates.sql | **NUEVO** - Tipos de cambio USD/MXN | -| `wallet_limits` | 08-wallet_limits.sql | **NUEVO** - Límites de operación | - -**Funciones:** update_wallet_balance, process_transaction, triggers, views - -> **Decisión:** USD como moneda principal, MXN mediante conversión. Ver DEC-001. - -### ml (5 tablas) - -| Tabla | Archivo | Descripción | -|-------|---------|-------------| -| `models` | 01-models.sql | Registro de modelos ML | -| `model_versions` | 02-model_versions.sql | **NUEVO** - Versionado de modelos | -| `predictions` | 03-predictions.sql | Predicciones generadas | -| `prediction_outcomes` | 04-prediction_outcomes.sql | Resultados de predicciones | -| `feature_store` | 05-feature_store.sql | Features pre-calculadas | - -### llm (5 tablas) - -| Tabla | Archivo | Descripción | -|-------|---------|-------------| -| `conversations` | 01-conversations.sql | Conversaciones con LLM | -| `messages` | 02-messages.sql | Mensajes de conversación | -| `user_preferences` | 03-user_preferences.sql | **NUEVO** - Preferencias de usuario | -| `user_memory` | 04-user_memory.sql | **NUEVO** - Memoria persistente | -| `embeddings` | 05-embeddings.sql | RAG - Embeddings (pgvector) | - -> **Decisión:** pgvector para embeddings. Ver DEC-004. - -### audit (7 tablas) - -| Tabla | Archivo | Descripción | -|-------|---------|-------------| -| `audit_logs` | 01-audit_logs.sql | Log general de auditoría | -| `security_events` | 02-security_events.sql | Eventos de seguridad | -| `system_events` | 03-system_events.sql | Eventos del sistema | -| `trading_audit` | 04-trading_audit.sql | Auditoría de trading | -| `api_request_logs` | 05-api_request_logs.sql | Logs de API | -| `data_access_logs` | 06-data_access_logs.sql | Acceso a datos sensibles (GDPR) | -| `compliance_logs` | 07-compliance_logs.sql | Cumplimiento regulatorio | - ---- - -## Diagrama de Relaciones - -``` -┌─────────────────────────────────────────────────────────────────────┐ -│ auth │ -│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ -│ │ users │──│ profiles │ │ sessions │ │ oauth_accounts │ │ -│ └────┬─────┘ └──────────┘ └──────────┘ └──────────────────┘ │ -│ │ │ -└───────┼─────────────────────────────────────────────────────────────┘ - │ - ├──────────────────────────┬──────────────────────────┐ - │ │ │ - ▼ ▼ ▼ -┌───────────────┐ ┌───────────────┐ ┌───────────────┐ -│ education │ │ trading │ │ investment │ -│ ┌───────────┐ │ │ ┌───────────┐ │ │ ┌───────────┐ │ -│ │ courses │ │ │ │ bots │─┼──────────┼─│ products │ │ -│ └─────┬─────┘ │ │ └─────┬─────┘ │ │ └─────┬─────┘ │ -│ │ │ │ │ │ │ │ │ -│ ┌─────▼─────┐ │ │ ┌─────▼─────┐ │ │ ┌─────▼─────┐ │ -│ │ lessons │ │ │ │ signals◄──┼─┼── ml │ │ accounts │ │ -│ └───────────┘ │ │ └─────┬─────┘ │ │ └─────┬─────┘ │ -│ │ │ │ │ │ │ │ -│ ┌───────────┐ │ │ ┌─────▼─────┐ │ │ ┌─────▼─────┐ │ -│ │enrollments│ │ │ │ positions │─┼──────────┼─│transactions│ │ -│ └───────────┘ │ │ └───────────┘ │ │ └───────────┘ │ -└───────────────┘ └───────────────┘ └───────────────┘ - │ │ │ - │ │ │ - └──────────────────────────┼──────────────────────────┘ - │ - ▼ - ┌───────────────┐ - │ financial │ - │ ┌───────────┐ │ - │ │ wallets │◄──── UNIFICADO - │ └─────┬─────┘ │ - │ │ │ - │ ┌─────▼─────┐ │ - │ │ payments │ │ - │ └───────────┘ │ - │ │ - │ ┌───────────┐ │ - │ │subscript. │ │ - │ └───────────┘ │ - └───────────────┘ - -┌───────────────┐ ┌───────────────┐ ┌───────────────┐ -│ ml │ │ llm │ │ audit │ -│ ┌───────────┐ │ │ ┌───────────┐ │ │ ┌───────────┐ │ -│ │ models │ │ │ │ conversat.│ │ │ │audit_logs │ │ -│ └─────┬─────┘ │ │ └─────┬─────┘ │ │ └───────────┘ │ -│ │ │ │ │ │ │ │ -│ ┌─────▼─────┐ │ │ ┌─────▼─────┐ │ │ ┌───────────┐ │ -│ │predictions├─┼──────────┼─│ embeddings│ │ │ │sec_events │ │ -│ └───────────┘ │ signals │ └───────────┘ │ │ └───────────┘ │ -└───────────────┘ └───────────────┘ └───────────────┘ -``` - ---- - -## Orden de Ejecución (create-database.sh) - -```bash -1. Extensions (uuid-ossp, pgcrypto, pg_trgm, btree_gin, vector) -2. Schemas (auth, education, financial, trading, investment, ml, llm, audit) -3. DDL por schema en orden de dependencia: - - auth (sin dependencias) - - education (depende de auth) - - financial (depende de auth) - - trading (depende de auth) - - investment(depende de auth, trading) - - ml (depende de auth, trading) - - llm (depende de auth) - - audit (depende de auth) -4. Seeds (prod o dev) -``` - ---- - -## Comandos de Uso - -```bash -# Crear base de datos nueva -./scripts/create-database.sh - -# Eliminar y recrear (desarrollo) -./scripts/drop-and-recreate-database.sh - -# Solo cargar seeds -./scripts/create-database.sh --seeds-only --env dev -``` - ---- - -## Notas Importantes - -- **TIMESTAMPTZ**: Todas las columnas de fecha usan timezone (DEC-007) -- **gen_random_uuid()**: Para generación de UUIDs (no uuid_generate_v4()) -- **Row Level Security (RLS)**: Implementar en fase de seguridad -- **Particionamiento**: Considerar para `predictions`, `audit_logs` en producción -- **Backups**: Configurar backup incremental cada 6 horas - ---- - -## Referencias - -- [DIRECTIVA-POLITICA-CARGA-LIMPIA.md](../DIRECTIVA-POLITICA-CARGA-LIMPIA.md) -- [DECISIONES-ARQUITECTONICAS.md](../../docs/99-analisis/DECISIONES-ARQUITECTONICAS.md) -- [PLAN-IMPLEMENTACION-CORRECCIONES.md](../../docs/99-analisis/PLAN-IMPLEMENTACION-CORRECCIONES.md) - ---- -*Última actualización: 2025-12-06* -*Generado por Requirements-Analyst Agent* diff --git a/apps/database/scripts/create-database.sh b/apps/database/scripts/create-database.sh deleted file mode 100755 index 80f5546..0000000 --- a/apps/database/scripts/create-database.sh +++ /dev/null @@ -1,308 +0,0 @@ -#!/bin/bash -# ============================================================================ -# CREATE DATABASE SCRIPT - OrbiQuant IA -# ============================================================================ -# -# Script de carga limpia para crear la base de datos desde DDL. -# Cumple con DIRECTIVA-POLITICA-CARGA-LIMPIA.md -# -# USO: -# ./create-database.sh # Crear BD (si no existe) -# ./create-database.sh --drop-first # Drop y recrear -# ./create-database.sh --seeds-only # Solo ejecutar seeds -# -# ============================================================================ - -set -e - -# Colores para output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -# Configuración -DB_NAME="${DB_NAME:-orbiquant}" -DB_USER="${DB_USER:-orbiquant_user}" -DB_PASSWORD="${DB_PASSWORD:-orbiquant_dev_2025}" -DB_HOST="${DB_HOST:-localhost}" -DB_PORT="${DB_PORT:-5432}" - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -DDL_DIR="$SCRIPT_DIR/../ddl" -SEEDS_DIR="$SCRIPT_DIR/../seeds" - -# Función de logging -log() { - echo -e "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1" -} - -log_success() { - echo -e "${GREEN}[✓]${NC} $1" -} - -log_warning() { - echo -e "${YELLOW}[!]${NC} $1" -} - -log_error() { - echo -e "${RED}[✗]${NC} $1" -} - -# Función para ejecutar SQL -run_sql() { - local file=$1 - local description=$2 - - if [ -f "$file" ]; then - log "Ejecutando: $description" - PGPASSWORD=$DB_PASSWORD psql -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME -f "$file" -q - log_success "$description" - else - log_warning "Archivo no encontrado: $file" - fi -} - -# Función para ejecutar SQL en orden -run_sql_dir() { - local dir=$1 - local description=$2 - - if [ -d "$dir" ]; then - log "Procesando directorio: $description" - for file in "$dir"/*.sql; do - if [ -f "$file" ]; then - local filename=$(basename "$file") - run_sql "$file" "$filename" - fi - done - else - log_warning "Directorio no encontrado: $dir" - fi -} - -# Verificar conexión -check_connection() { - log "Verificando conexión a PostgreSQL..." - if PGPASSWORD=$DB_PASSWORD psql -h $DB_HOST -p $DB_PORT -U $DB_USER -d postgres -c '\q' 2>/dev/null; then - log_success "Conexión exitosa" - else - log_error "No se puede conectar a PostgreSQL" - exit 1 - fi -} - -# Drop database si existe -drop_database() { - log "Eliminando base de datos $DB_NAME si existe..." - PGPASSWORD=$DB_PASSWORD psql -h $DB_HOST -p $DB_PORT -U $DB_USER -d postgres -c "DROP DATABASE IF EXISTS $DB_NAME;" -q - log_success "Base de datos eliminada" -} - -# Crear database -create_database() { - log "Creando base de datos $DB_NAME..." - PGPASSWORD=$DB_PASSWORD psql -h $DB_HOST -p $DB_PORT -U $DB_USER -d postgres -c "CREATE DATABASE $DB_NAME WITH ENCODING 'UTF8' LC_COLLATE='en_US.UTF-8' LC_CTYPE='en_US.UTF-8' TEMPLATE=template0;" -q 2>/dev/null || true - log_success "Base de datos creada" -} - -# Crear schemas -create_schemas() { - log "Creando schemas..." - PGPASSWORD=$DB_PASSWORD psql -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME -q << EOF --- Schemas principales -CREATE SCHEMA IF NOT EXISTS auth; -CREATE SCHEMA IF NOT EXISTS education; -CREATE SCHEMA IF NOT EXISTS trading; -CREATE SCHEMA IF NOT EXISTS investment; -CREATE SCHEMA IF NOT EXISTS financial; -CREATE SCHEMA IF NOT EXISTS ml; -CREATE SCHEMA IF NOT EXISTS llm; -CREATE SCHEMA IF NOT EXISTS audit; - --- Comentarios -COMMENT ON SCHEMA auth IS 'Autenticación y usuarios'; -COMMENT ON SCHEMA education IS 'Plataforma educativa'; -COMMENT ON SCHEMA trading IS 'Trading y paper engine'; -COMMENT ON SCHEMA investment IS 'Cuentas PAMM'; -COMMENT ON SCHEMA financial IS 'Pagos, suscripciones, wallets'; -COMMENT ON SCHEMA ml IS 'Machine Learning signals'; -COMMENT ON SCHEMA llm IS 'LLM Agent'; -COMMENT ON SCHEMA audit IS 'Auditoría y logs'; - --- Search path -ALTER DATABASE $DB_NAME SET search_path TO auth, public; -EOF - log_success "Schemas creados" -} - -# Cargar extensiones -load_extensions() { - log "Cargando extensiones..." - PGPASSWORD=$DB_PASSWORD psql -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME -q << EOF -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -CREATE EXTENSION IF NOT EXISTS "btree_gin"; --- CREATE EXTENSION IF NOT EXISTS "vector"; -- Descomentar cuando se instale pgvector -EOF - log_success "Extensiones cargadas" -} - -# Cargar DDL por schema -load_ddl() { - local schema=$1 - local schema_dir="$DDL_DIR/schemas/$schema" - - log "Cargando DDL para schema: $schema" - - # 1. Enums primero - if [ -f "$schema_dir/00-enums.sql" ]; then - run_sql "$schema_dir/00-enums.sql" "$schema: ENUMs" - fi - - # 2. Tablas en orden - run_sql_dir "$schema_dir/tables" "$schema: Tables" - - # 3. Funciones - run_sql_dir "$schema_dir/functions" "$schema: Functions" - - # 4. Triggers - run_sql_dir "$schema_dir/triggers" "$schema: Triggers" - - # 5. Views - run_sql_dir "$schema_dir/views" "$schema: Views" - - # 6. Indexes adicionales - if [ -f "$schema_dir/99-indexes.sql" ]; then - run_sql "$schema_dir/99-indexes.sql" "$schema: Indexes" - fi - - log_success "Schema $schema cargado" -} - -# Cargar todos los DDL -load_all_ddl() { - log "Cargando todos los DDL..." - - # Orden de carga (respeta dependencias) - local schemas=("auth" "education" "financial" "trading" "investment" "ml" "llm" "audit") - - for schema in "${schemas[@]}"; do - if [ -d "$DDL_DIR/schemas/$schema" ]; then - load_ddl "$schema" - else - log_warning "Schema $schema no tiene DDL definido" - fi - done - - log_success "Todos los DDL cargados" -} - -# Cargar seeds -load_seeds() { - local env=${1:-prod} - local seeds_path="$SEEDS_DIR/$env" - - log "Cargando seeds ($env)..." - - if [ -d "$seeds_path" ]; then - for schema_dir in "$seeds_path"/*/; do - if [ -d "$schema_dir" ]; then - local schema=$(basename "$schema_dir") - run_sql_dir "$schema_dir" "Seeds: $schema" - fi - done - log_success "Seeds cargados" - else - log_warning "No hay seeds para ambiente: $env" - fi -} - -# Validar integridad -validate_database() { - log "Validando integridad de la base de datos..." - - # Contar tablas por schema - local result=$(PGPASSWORD=$DB_PASSWORD psql -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME -t -c " - SELECT schemaname, COUNT(*) - FROM pg_tables - WHERE schemaname IN ('auth', 'education', 'trading', 'investment', 'financial', 'ml', 'llm', 'audit') - GROUP BY schemaname - ORDER BY schemaname; - ") - - echo "" - echo "=== Resumen de Tablas por Schema ===" - echo "$result" - echo "" - - # Verificar FKs - local fk_count=$(PGPASSWORD=$DB_PASSWORD psql -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME -t -c " - SELECT COUNT(*) FROM information_schema.table_constraints WHERE constraint_type = 'FOREIGN KEY'; - ") - log_success "Foreign Keys: $fk_count" - - log_success "Validación completada" -} - -# Main -main() { - echo "" - echo "==============================================" - echo " OrbiQuant IA - Database Setup" - echo " Política de Carga Limpia (DDL-First)" - echo "==============================================" - echo "" - - local drop_first=false - local seeds_only=false - local seed_env="prod" - - # Parse arguments - while [[ $# -gt 0 ]]; do - case $1 in - --drop-first) - drop_first=true - shift - ;; - --seeds-only) - seeds_only=true - shift - ;; - --env) - seed_env=$2 - shift 2 - ;; - *) - log_error "Argumento desconocido: $1" - exit 1 - ;; - esac - done - - check_connection - - if [ "$seeds_only" = true ]; then - load_seeds "$seed_env" - exit 0 - fi - - if [ "$drop_first" = true ]; then - drop_database - fi - - create_database - create_schemas - load_extensions - load_all_ddl - load_seeds "$seed_env" - validate_database - - echo "" - log_success "Base de datos creada exitosamente!" - echo "" -} - -main "$@" diff --git a/apps/database/scripts/drop-and-recreate-database.sh b/apps/database/scripts/drop-and-recreate-database.sh deleted file mode 100755 index 1bfec57..0000000 --- a/apps/database/scripts/drop-and-recreate-database.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash -# ============================================================================ -# DROP AND RECREATE DATABASE - OrbiQuant IA -# ============================================================================ -# -# Alias para carga limpia completa. -# Wrapper de create-database.sh con --drop-first -# -# ============================================================================ - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -exec "$SCRIPT_DIR/create-database.sh" --drop-first "$@" diff --git a/apps/database/scripts/migrate_all_tickers.sh b/apps/database/scripts/migrate_all_tickers.sh deleted file mode 100755 index 62f2689..0000000 --- a/apps/database/scripts/migrate_all_tickers.sh +++ /dev/null @@ -1,96 +0,0 @@ -#!/bin/bash -# Migrate all tickers from MySQL to PostgreSQL -# Run in background: nohup ./migrate_all_tickers.sh > migration.log 2>&1 & - -set -e - -MYSQL_HOST="72.60.226.4" -MYSQL_USER="root" -MYSQL_PASS="AfcItz2391,." -MYSQL_DB="db_trading_meta" - -PG_USER="orbiquant_user" -PG_PASS="orbiquant_dev_2025" -PG_DB="orbiquant_trading" - -# Ticker mapping -declare -A TICKERS=( - ["C:EURUSD"]="2" - ["C:GBPUSD"]="3" - ["C:USDJPY"]="4" - ["C:USDCAD"]="5" - ["C:AUDUSD"]="6" - ["C:NZDUSD"]="7" - ["C:EURGBP"]="8" - ["C:EURAUD"]="9" - ["C:EURCHF"]="10" - ["C:GBPJPY"]="11" - ["C:GBPAUD"]="12" - ["C:GBPCAD"]="13" - ["C:GBPNZD"]="14" - ["C:AUDCAD"]="15" - ["C:AUDCHF"]="16" - ["C:AUDNZD"]="17" - ["C:XAUUSD"]="18" -) - -migrate_ticker() { - local mysql_sym=$1 - local pg_id=$2 - local tmpfile="/tmp/ticker_${pg_id}.tsv" - - echo "[$(date '+%Y-%m-%d %H:%M:%S')] Starting $mysql_sym (id=$pg_id)..." - - # Export from MySQL - mysql -h $MYSQL_HOST -u $MYSQL_USER -p"$MYSQL_PASS" $MYSQL_DB \ - --batch --skip-column-names \ - -e "SELECT $pg_id, date_agg, open, high, low, close, volume, vwap, ts FROM tickers_agg_data WHERE ticker='$mysql_sym' ORDER BY date_agg" 2>/dev/null \ - > "$tmpfile" - - local count=$(wc -l < "$tmpfile") - echo "[$(date '+%Y-%m-%d %H:%M:%S')] Exported $count rows for $mysql_sym" - - # Truncate staging and import - PGPASSWORD=$PG_PASS psql -h localhost -U $PG_USER -d $PG_DB -c "TRUNCATE market_data.ohlcv_5m_staging;" >/dev/null - - PGPASSWORD=$PG_PASS psql -h localhost -U $PG_USER -d $PG_DB \ - -c "\COPY market_data.ohlcv_5m_staging FROM '$tmpfile' WITH (FORMAT text, DELIMITER E'\t')" >/dev/null - - # Upsert to main table - local inserted=$(PGPASSWORD=$PG_PASS psql -h localhost -U $PG_USER -d $PG_DB -t -c " - INSERT INTO market_data.ohlcv_5m (ticker_id, timestamp, open, high, low, close, volume, vwap, ts_epoch) - SELECT DISTINCT ON (ticker_id, timestamp) - ticker_id, timestamp, open, high, low, close, volume, vwap, ts_epoch - FROM market_data.ohlcv_5m_staging - ON CONFLICT (ticker_id, timestamp) DO NOTHING; - SELECT COUNT(*) FROM market_data.ohlcv_5m WHERE ticker_id = $pg_id; - ") - - echo "[$(date '+%Y-%m-%d %H:%M:%S')] Completed $mysql_sym: $inserted rows in PostgreSQL" - rm -f "$tmpfile" -} - -echo "============================================" -echo "Starting full migration at $(date)" -echo "============================================" - -for mysql_sym in "${!TICKERS[@]}"; do - pg_id=${TICKERS[$mysql_sym]} - migrate_ticker "$mysql_sym" "$pg_id" -done - -echo "" -echo "============================================" -echo "Migration complete at $(date)" -echo "============================================" - -# Final verification -echo "" -echo "Final row counts:" -PGPASSWORD=$PG_PASS psql -h localhost -U $PG_USER -d $PG_DB -c " -SELECT t.symbol, COUNT(*) as rows -FROM market_data.ohlcv_5m o -JOIN market_data.tickers t ON t.id = o.ticker_id -GROUP BY t.symbol -ORDER BY t.symbol; -" diff --git a/apps/database/scripts/migrate_direct.sh b/apps/database/scripts/migrate_direct.sh deleted file mode 100755 index 5558d66..0000000 --- a/apps/database/scripts/migrate_direct.sh +++ /dev/null @@ -1,88 +0,0 @@ -#!/bin/bash -# Direct MySQL to PostgreSQL migration script -# Uses mysqldump and psql for direct data transfer - -set -e - -# Configuration -MYSQL_HOST="72.60.226.4" -MYSQL_PORT="3306" -MYSQL_USER="root" -MYSQL_PASS="AfcItz2391,." -MYSQL_DB="db_trading_meta" - -PG_HOST="localhost" -PG_PORT="5432" -PG_USER="orbiquant_user" -PG_PASS="orbiquant_dev_2025" -PG_DB="orbiquant_trading" - -# Ticker mapping: MySQL symbol -> (PG symbol, ticker_id) -declare -A TICKER_MAP=( - ["X:BTCUSD"]="1" - ["C:EURUSD"]="2" - ["C:GBPUSD"]="3" - ["C:USDJPY"]="4" - ["C:USDCAD"]="5" - ["C:AUDUSD"]="6" - ["C:NZDUSD"]="7" - ["C:EURGBP"]="8" - ["C:EURAUD"]="9" - ["C:EURCHF"]="10" - ["C:GBPJPY"]="11" - ["C:GBPAUD"]="12" - ["C:GBPCAD"]="13" - ["C:GBPNZD"]="14" - ["C:AUDCAD"]="15" - ["C:AUDCHF"]="16" - ["C:AUDNZD"]="17" - ["C:XAUUSD"]="18" -) - -migrate_ticker() { - local mysql_ticker=$1 - local pg_ticker_id=$2 - - echo "Migrating $mysql_ticker (ticker_id=$pg_ticker_id)..." - - # Export from MySQL and import to PostgreSQL - mysql -h $MYSQL_HOST -P $MYSQL_PORT -u $MYSQL_USER -p"$MYSQL_PASS" $MYSQL_DB \ - -N -e "SELECT $pg_ticker_id, date_agg, open, high, low, close, volume, vwap, ts FROM tickers_agg_data WHERE ticker='$mysql_ticker' ORDER BY date_agg" \ - | PGPASSWORD=$PG_PASS psql -h $PG_HOST -p $PG_PORT -U $PG_USER -d $PG_DB \ - -c "COPY market_data.ohlcv_5m (ticker_id, timestamp, open, high, low, close, volume, vwap, ts_epoch) FROM STDIN WITH (FORMAT csv, DELIMITER E'\t')" - - echo "Completed $mysql_ticker" -} - -# Main -echo "Starting migration..." -echo "MySQL: $MYSQL_HOST:$MYSQL_PORT/$MYSQL_DB" -echo "PostgreSQL: $PG_HOST:$PG_PORT/$PG_DB" -echo "" - -# Migrate specific ticker or all -if [ -n "$1" ]; then - # Migrate specific ticker - mysql_ticker=$1 - pg_ticker_id=${TICKER_MAP[$mysql_ticker]} - if [ -z "$pg_ticker_id" ]; then - echo "Unknown ticker: $mysql_ticker" - exit 1 - fi - migrate_ticker "$mysql_ticker" "$pg_ticker_id" -else - # Migrate all tickers - for mysql_ticker in "${!TICKER_MAP[@]}"; do - pg_ticker_id=${TICKER_MAP[$mysql_ticker]} - migrate_ticker "$mysql_ticker" "$pg_ticker_id" - done -fi - -echo "" -echo "Migration complete!" - -# Verify counts -echo "" -echo "Verification:" -PGPASSWORD=$PG_PASS psql -h $PG_HOST -p $PG_PORT -U $PG_USER -d $PG_DB \ - -c "SELECT t.symbol, COUNT(*) as rows FROM market_data.ohlcv_5m o JOIN market_data.tickers t ON t.id = o.ticker_id GROUP BY t.symbol ORDER BY t.symbol" diff --git a/apps/database/scripts/migrate_mysql_to_postgres.py b/apps/database/scripts/migrate_mysql_to_postgres.py deleted file mode 100644 index 0e584e2..0000000 --- a/apps/database/scripts/migrate_mysql_to_postgres.py +++ /dev/null @@ -1,393 +0,0 @@ -#!/usr/bin/env python3 -""" -MySQL to PostgreSQL Migration Script -OrbiQuant IA Trading Platform - -Migrates market data from MySQL (remote) to PostgreSQL (local). - -Usage: - python migrate_mysql_to_postgres.py --full # Full migration - python migrate_mysql_to_postgres.py --incremental # Only new data - python migrate_mysql_to_postgres.py --ticker BTCUSD # Specific ticker -""" - -import os -import sys -import argparse -import logging -from datetime import datetime, timedelta -from typing import Optional, List, Dict, Any -import pandas as pd -import numpy as np -from tqdm import tqdm - -# Database connections -import mysql.connector -import psycopg2 -from psycopg2.extras import execute_values - -# Configuration -MYSQL_CONFIG = { - 'host': '72.60.226.4', - 'port': 3306, - 'user': 'root', - 'password': 'AfcItz2391,.', - 'database': 'db_trading_meta' -} - -POSTGRES_CONFIG = { - 'host': 'localhost', - 'port': 5432, - 'user': 'orbiquant_user', - 'password': 'orbiquant_dev_2025', - 'database': 'orbiquant_trading' -} - -# Ticker mapping (MySQL symbol -> asset_type) -TICKER_MAPPING = { - 'X:BTCUSD': ('BTCUSD', 'crypto', 'BTC', 'USD'), - 'C:EURUSD': ('EURUSD', 'forex', 'EUR', 'USD'), - 'C:GBPUSD': ('GBPUSD', 'forex', 'GBP', 'USD'), - 'C:USDJPY': ('USDJPY', 'forex', 'USD', 'JPY'), - 'C:USDCAD': ('USDCAD', 'forex', 'USD', 'CAD'), - 'C:AUDUSD': ('AUDUSD', 'forex', 'AUD', 'USD'), - 'C:NZDUSD': ('NZDUSD', 'forex', 'NZD', 'USD'), - 'C:EURGBP': ('EURGBP', 'forex', 'EUR', 'GBP'), - 'C:EURAUD': ('EURAUD', 'forex', 'EUR', 'AUD'), - 'C:EURCHF': ('EURCHF', 'forex', 'EUR', 'CHF'), - 'C:GBPJPY': ('GBPJPY', 'forex', 'GBP', 'JPY'), - 'C:GBPAUD': ('GBPAUD', 'forex', 'GBP', 'AUD'), - 'C:GBPCAD': ('GBPCAD', 'forex', 'GBP', 'CAD'), - 'C:GBPNZD': ('GBPNZD', 'forex', 'GBP', 'NZD'), - 'C:AUDCAD': ('AUDCAD', 'forex', 'AUD', 'CAD'), - 'C:AUDCHF': ('AUDCHF', 'forex', 'AUD', 'CHF'), - 'C:AUDNZD': ('AUDNZD', 'forex', 'AUD', 'NZD'), - 'C:XAUUSD': ('XAUUSD', 'commodity', 'XAU', 'USD'), -} - -# Logging setup -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(message)s' -) -logger = logging.getLogger(__name__) - - -class MySQLConnection: - """MySQL connection manager.""" - - def __init__(self, config: Dict[str, Any]): - self.config = config - self.conn = None - - def __enter__(self): - self.conn = mysql.connector.connect(**self.config) - return self.conn - - def __exit__(self, exc_type, exc_val, exc_tb): - if self.conn: - self.conn.close() - - -class PostgresConnection: - """PostgreSQL connection manager.""" - - def __init__(self, config: Dict[str, Any]): - self.config = config - self.conn = None - - def __enter__(self): - self.conn = psycopg2.connect(**self.config) - return self.conn - - def __exit__(self, exc_type, exc_val, exc_tb): - if self.conn: - self.conn.close() - - -def create_database_if_not_exists(): - """Create PostgreSQL database if it doesn't exist.""" - config = POSTGRES_CONFIG.copy() - db_name = config.pop('database') - - try: - conn = psycopg2.connect(**config, database='postgres') - conn.autocommit = True - cursor = conn.cursor() - - # Check if database exists - cursor.execute( - "SELECT 1 FROM pg_catalog.pg_database WHERE datname = %s", - (db_name,) - ) - - if not cursor.fetchone(): - cursor.execute(f'CREATE DATABASE {db_name}') - logger.info(f"Created database: {db_name}") - else: - logger.info(f"Database {db_name} already exists") - - cursor.close() - conn.close() - - except Exception as e: - logger.error(f"Error creating database: {e}") - raise - - -def run_migrations(): - """Run SQL migrations.""" - migration_file = os.path.join( - os.path.dirname(__file__), - '..', 'migrations', '001_create_market_data_schema.sql' - ) - - with PostgresConnection(POSTGRES_CONFIG) as conn: - cursor = conn.cursor() - - # Read and execute migration - with open(migration_file, 'r') as f: - sql = f.read() - - try: - cursor.execute(sql) - conn.commit() - logger.info("Migrations completed successfully") - except Exception as e: - conn.rollback() - logger.warning(f"Migration error (may already exist): {e}") - - cursor.close() - - -def insert_tickers(): - """Insert ticker master data.""" - with PostgresConnection(POSTGRES_CONFIG) as conn: - cursor = conn.cursor() - - for mysql_symbol, (symbol, asset_type, base, quote) in TICKER_MAPPING.items(): - try: - cursor.execute(""" - INSERT INTO market_data.tickers - (symbol, name, asset_type, base_currency, quote_currency) - VALUES (%s, %s, %s, %s, %s) - ON CONFLICT (symbol) DO NOTHING - """, (symbol, f"{base}/{quote}", asset_type, base, quote)) - except Exception as e: - logger.warning(f"Error inserting ticker {symbol}: {e}") - - conn.commit() - cursor.close() - logger.info(f"Inserted {len(TICKER_MAPPING)} tickers") - - -def get_ticker_id_map() -> Dict[str, int]: - """Get mapping of symbol to ticker_id.""" - with PostgresConnection(POSTGRES_CONFIG) as conn: - cursor = conn.cursor() - cursor.execute("SELECT symbol, id FROM market_data.tickers") - result = {row[0]: row[1] for row in cursor.fetchall()} - cursor.close() - return result - - -def get_last_timestamp(ticker_id: int) -> Optional[datetime]: - """Get the last timestamp for a ticker in PostgreSQL.""" - with PostgresConnection(POSTGRES_CONFIG) as conn: - cursor = conn.cursor() - cursor.execute(""" - SELECT MAX(timestamp) FROM market_data.ohlcv_5m - WHERE ticker_id = %s - """, (ticker_id,)) - result = cursor.fetchone()[0] - cursor.close() - return result - - -def migrate_ohlcv_data( - mysql_ticker: str, - pg_ticker_id: int, - start_date: Optional[datetime] = None, - batch_size: int = 50000 -): - """Migrate OHLCV data for a specific ticker.""" - - # Build query - query = f""" - SELECT - date_agg, - open, - high, - low, - close, - volume, - vwap, - ts - FROM tickers_agg_data - WHERE ticker = '{mysql_ticker}' - """ - - if start_date: - query += f" AND date_agg > '{start_date.strftime('%Y-%m-%d %H:%M:%S')}'" - - query += " ORDER BY date_agg" - - with MySQLConnection(MYSQL_CONFIG) as mysql_conn: - mysql_cursor = mysql_conn.cursor() - mysql_cursor.execute(query) - - total_rows = 0 - batch = [] - - with PostgresConnection(POSTGRES_CONFIG) as pg_conn: - pg_cursor = pg_conn.cursor() - - for row in tqdm(mysql_cursor, desc=f"Migrating {mysql_ticker}"): - date_agg, open_p, high, low, close, volume, vwap, ts = row - - batch.append(( - pg_ticker_id, - date_agg, - float(open_p), - float(high), - float(low), - float(close), - float(volume), - float(vwap) if vwap else None, - int(ts) if ts else None - )) - - if len(batch) >= batch_size: - insert_batch(pg_cursor, batch) - pg_conn.commit() - total_rows += len(batch) - batch = [] - - # Insert remaining - if batch: - insert_batch(pg_cursor, batch) - pg_conn.commit() - total_rows += len(batch) - - pg_cursor.close() - - mysql_cursor.close() - - logger.info(f"Migrated {total_rows} rows for {mysql_ticker}") - return total_rows - - -def insert_batch(cursor, batch: List[tuple]): - """Insert batch of OHLCV data.""" - query = """ - INSERT INTO market_data.ohlcv_5m - (ticker_id, timestamp, open, high, low, close, volume, vwap, ts_epoch) - VALUES %s - ON CONFLICT (ticker_id, timestamp) DO NOTHING - """ - execute_values(cursor, query, batch) - - -def migrate_all(incremental: bool = False, tickers: Optional[List[str]] = None): - """Migrate all data from MySQL to PostgreSQL.""" - - logger.info("Starting migration...") - - # Step 1: Create database and run migrations - create_database_if_not_exists() - run_migrations() - - # Step 2: Insert tickers - insert_tickers() - - # Step 3: Get ticker ID mapping - ticker_id_map = get_ticker_id_map() - logger.info(f"Ticker ID map: {ticker_id_map}") - - # Step 4: Migrate OHLCV data - total_migrated = 0 - - for mysql_symbol, (pg_symbol, _, _, _) in TICKER_MAPPING.items(): - if tickers and pg_symbol not in tickers: - continue - - if pg_symbol not in ticker_id_map: - logger.warning(f"Ticker {pg_symbol} not found in PostgreSQL") - continue - - ticker_id = ticker_id_map[pg_symbol] - - # Check for incremental - start_date = None - if incremental: - start_date = get_last_timestamp(ticker_id) - if start_date: - logger.info(f"Incremental from {start_date} for {pg_symbol}") - - rows = migrate_ohlcv_data(mysql_symbol, ticker_id, start_date) - total_migrated += rows - - logger.info(f"Migration complete. Total rows: {total_migrated}") - - -def verify_migration(): - """Verify migration by comparing row counts.""" - logger.info("Verifying migration...") - - with MySQLConnection(MYSQL_CONFIG) as mysql_conn: - mysql_cursor = mysql_conn.cursor() - - with PostgresConnection(POSTGRES_CONFIG) as pg_conn: - pg_cursor = pg_conn.cursor() - - for mysql_symbol, (pg_symbol, _, _, _) in TICKER_MAPPING.items(): - # MySQL count - mysql_cursor.execute( - f"SELECT COUNT(*) FROM tickers_agg_data WHERE ticker = '{mysql_symbol}'" - ) - mysql_count = mysql_cursor.fetchone()[0] - - # PostgreSQL count - pg_cursor.execute(""" - SELECT COUNT(*) FROM market_data.ohlcv_5m o - JOIN market_data.tickers t ON t.id = o.ticker_id - WHERE t.symbol = %s - """, (pg_symbol,)) - pg_count = pg_cursor.fetchone()[0] - - status = "✅" if mysql_count == pg_count else "❌" - logger.info(f"{status} {pg_symbol}: MySQL={mysql_count}, PostgreSQL={pg_count}") - - pg_cursor.close() - mysql_cursor.close() - - -def main(): - parser = argparse.ArgumentParser(description='MySQL to PostgreSQL Migration') - parser.add_argument('--full', action='store_true', help='Full migration') - parser.add_argument('--incremental', action='store_true', help='Incremental migration') - parser.add_argument('--ticker', type=str, help='Specific ticker to migrate') - parser.add_argument('--verify', action='store_true', help='Verify migration') - parser.add_argument('--schema-only', action='store_true', help='Only create schema') - - args = parser.parse_args() - - if args.schema_only: - create_database_if_not_exists() - run_migrations() - insert_tickers() - logger.info("Schema created successfully") - return - - if args.verify: - verify_migration() - return - - tickers = [args.ticker] if args.ticker else None - incremental = args.incremental - - migrate_all(incremental=incremental, tickers=tickers) - - -if __name__ == '__main__': - main() diff --git a/apps/database/scripts/validate-ddl.sh b/apps/database/scripts/validate-ddl.sh deleted file mode 100644 index d926a11..0000000 --- a/apps/database/scripts/validate-ddl.sh +++ /dev/null @@ -1,305 +0,0 @@ -#!/bin/bash - -# ============================================================================ -# OrbiQuant IA - Trading Platform -# Script: validate-ddl.sh -# Description: Validates and executes all DDL scripts in correct order -# ============================================================================ - -set -e # Exit on error - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -# Load environment variables -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -DB_DIR="$(dirname "$SCRIPT_DIR")" -DDL_DIR="$DB_DIR/ddl" -ENV_FILE="$DB_DIR/.env" - -if [ -f "$ENV_FILE" ]; then - source "$ENV_FILE" -else - echo -e "${RED}Error: .env file not found at $ENV_FILE${NC}" - exit 1 -fi - -# Database connection parameters -export PGHOST="${DB_HOST:-localhost}" -export PGPORT="${DB_PORT:-5432}" -export PGDATABASE="${DB_NAME:-orbiquant_trading}" -export PGUSER="${DB_USER:-orbiquant_user}" -export PGPASSWORD="${DB_PASSWORD}" - -# Output file for combined SQL -MASTER_SQL="/tmp/orbiquant_ddl_master.sql" -ERROR_LOG="/tmp/orbiquant_ddl_errors.log" - -echo -e "${BLUE}============================================${NC}" -echo -e "${BLUE}OrbiQuant Trading Platform - DDL Validation${NC}" -echo -e "${BLUE}============================================${NC}" -echo "" - -# Check PostgreSQL connection -echo -e "${YELLOW}[1/5] Checking PostgreSQL connection...${NC}" -if pg_isready -h "$PGHOST" -p "$PGPORT" > /dev/null 2>&1; then - echo -e "${GREEN}✓ PostgreSQL is ready${NC}" -else - echo -e "${RED}✗ PostgreSQL is not ready${NC}" - exit 1 -fi - -# Check if database exists, create if not -echo -e "${YELLOW}[2/5] Checking database existence...${NC}" -DB_EXISTS=$(psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname='$PGDATABASE'") -if [ "$DB_EXISTS" != "1" ]; then - echo -e "${YELLOW}Database $PGDATABASE does not exist. Creating...${NC}" - psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d postgres -c "CREATE DATABASE $PGDATABASE;" - echo -e "${GREEN}✓ Database created${NC}" -else - echo -e "${GREEN}✓ Database exists${NC}" -fi - -# Generate master SQL file -echo -e "${YELLOW}[3/5] Generating master SQL script...${NC}" -cat > "$MASTER_SQL" << 'EOF' --- ============================================================================ --- OrbiQuant IA - Trading Platform --- Master DDL Script - Auto-generated --- ============================================================================ - -\set ON_ERROR_STOP on -\timing on - --- Drop existing schemas (CASCADE will drop all dependent objects) -DO $$ -BEGIN - DROP SCHEMA IF EXISTS audit CASCADE; - DROP SCHEMA IF EXISTS llm CASCADE; - DROP SCHEMA IF EXISTS ml CASCADE; - DROP SCHEMA IF EXISTS financial CASCADE; - DROP SCHEMA IF EXISTS investment CASCADE; - DROP SCHEMA IF EXISTS trading CASCADE; - DROP SCHEMA IF EXISTS education CASCADE; - DROP SCHEMA IF EXISTS auth CASCADE; - RAISE NOTICE 'All schemas dropped successfully'; -EXCEPTION - WHEN OTHERS THEN - RAISE NOTICE 'Error dropping schemas: %', SQLERRM; -END $$; - -EOF - -# Add extensions -echo -e " ${BLUE}→${NC} Adding extensions..." -if [ -f "$DDL_DIR/00-extensions.sql" ]; then - echo "-- Extensions" >> "$MASTER_SQL" - cat "$DDL_DIR/00-extensions.sql" >> "$MASTER_SQL" - echo "" >> "$MASTER_SQL" -else - echo -e "${RED}Warning: 00-extensions.sql not found${NC}" -fi - -# Add schemas -echo -e " ${BLUE}→${NC} Adding schemas..." -if [ -f "$DDL_DIR/01-schemas.sql" ]; then - echo "-- Schemas" >> "$MASTER_SQL" - cat "$DDL_DIR/01-schemas.sql" >> "$MASTER_SQL" - echo "" >> "$MASTER_SQL" -else - echo -e "${RED}Warning: 01-schemas.sql not found${NC}" -fi - -# Schema order (important for foreign key dependencies) -SCHEMAS=("auth" "education" "trading" "investment" "financial" "ml" "llm" "audit") - -# Process each schema -for schema in "${SCHEMAS[@]}"; do - echo -e " ${BLUE}→${NC} Processing schema: $schema" - - SCHEMA_DIR="$DDL_DIR/schemas/$schema" - - if [ ! -d "$SCHEMA_DIR" ]; then - echo -e "${YELLOW} Warning: Schema directory not found: $SCHEMA_DIR${NC}" - continue - fi - - echo "" >> "$MASTER_SQL" - echo "-- ============================================================================" >> "$MASTER_SQL" - echo "-- Schema: $schema" >> "$MASTER_SQL" - echo "-- ============================================================================" >> "$MASTER_SQL" - echo "" >> "$MASTER_SQL" - - # Add enums first (try both 00-enums.sql and 01-enums.sql) - for enum_file in "$SCHEMA_DIR/00-enums.sql" "$SCHEMA_DIR/01-enums.sql"; do - if [ -f "$enum_file" ]; then - echo " ${BLUE}→${NC} Adding enums from $(basename "$enum_file")" - echo "-- Enums for $schema" >> "$MASTER_SQL" - cat "$enum_file" >> "$MASTER_SQL" - echo "" >> "$MASTER_SQL" - break - fi - done - - # Add tables in numeric order - if [ -d "$SCHEMA_DIR/tables" ]; then - echo " ${BLUE}→${NC} Adding tables..." - for table_file in $(ls "$SCHEMA_DIR/tables/"*.sql 2>/dev/null | sort -V); do - echo " - $(basename "$table_file")" - echo "-- Table: $(basename "$table_file" .sql)" >> "$MASTER_SQL" - cat "$table_file" >> "$MASTER_SQL" - echo "" >> "$MASTER_SQL" - done - fi - - # Add functions - if [ -d "$SCHEMA_DIR/functions" ]; then - echo " ${BLUE}→${NC} Adding functions..." - for func_file in $(ls "$SCHEMA_DIR/functions/"*.sql 2>/dev/null | sort -V); do - echo " - $(basename "$func_file")" - echo "-- Function: $(basename "$func_file" .sql)" >> "$MASTER_SQL" - cat "$func_file" >> "$MASTER_SQL" - echo "" >> "$MASTER_SQL" - done - fi -done - -# Add summary query at the end -cat >> "$MASTER_SQL" << 'EOF' - --- ============================================================================ --- Summary: Count created objects --- ============================================================================ - -\echo '' -\echo '============================================' -\echo 'Database Objects Summary' -\echo '============================================' - -SELECT - schemaname as schema, - COUNT(*) as table_count -FROM pg_tables -WHERE schemaname IN ('auth', 'education', 'trading', 'investment', 'financial', 'ml', 'llm', 'audit') -GROUP BY schemaname -ORDER BY schemaname; - -\echo '' -\echo 'Functions by Schema:' - -SELECT - n.nspname as schema, - COUNT(*) as function_count -FROM pg_proc p -JOIN pg_namespace n ON p.pronamespace = n.oid -WHERE n.nspname IN ('auth', 'education', 'trading', 'investment', 'financial', 'ml', 'llm', 'audit') -GROUP BY n.nspname -ORDER BY n.nspname; - -\echo '' -\echo 'Enums by Schema:' - -SELECT - n.nspname as schema, - COUNT(*) as enum_count -FROM pg_type t -JOIN pg_namespace n ON t.typnamespace = n.oid -WHERE t.typtype = 'e' - AND n.nspname IN ('auth', 'education', 'trading', 'investment', 'financial', 'ml', 'llm', 'audit') -GROUP BY n.nspname -ORDER BY n.nspname; - -\echo '' -\echo '============================================' -\echo 'DDL Validation Complete' -\echo '============================================' - -EOF - -echo -e "${GREEN}✓ Master SQL script generated: $MASTER_SQL${NC}" - -# Execute the master SQL file -echo -e "${YELLOW}[4/5] Executing DDL scripts...${NC}" -echo -e "${BLUE}Database: $PGDATABASE${NC}" -echo -e "${BLUE}User: $PGUSER${NC}" -echo -e "${BLUE}Host: $PGHOST:$PGPORT${NC}" -echo "" - -if psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -f "$MASTER_SQL" 2> "$ERROR_LOG"; then - echo "" - echo -e "${GREEN}✓ DDL execution completed successfully${NC}" - - # Show any warnings (if error log has content but exit code was 0) - if [ -s "$ERROR_LOG" ]; then - echo -e "${YELLOW}Warnings/Notices:${NC}" - cat "$ERROR_LOG" - fi -else - echo "" - echo -e "${RED}✗ DDL execution failed${NC}" - echo -e "${RED}Error details:${NC}" - cat "$ERROR_LOG" - - echo "" - echo -e "${YELLOW}Analyzing errors...${NC}" - - # Try to identify which file caused the error - if grep -q "syntax error" "$ERROR_LOG"; then - echo -e "${RED}Syntax errors found:${NC}" - grep -A 2 "syntax error" "$ERROR_LOG" - fi - - if grep -q "does not exist" "$ERROR_LOG"; then - echo -e "${RED}Missing dependencies (tables/functions):${NC}" - grep "does not exist" "$ERROR_LOG" - fi - - if grep -q "violates foreign key" "$ERROR_LOG"; then - echo -e "${RED}Foreign key violations:${NC}" - grep "violates foreign key" "$ERROR_LOG" - fi - - exit 1 -fi - -# Final validation -echo -e "${YELLOW}[5/5] Final validation...${NC}" - -# Count created objects -TOTAL_TABLES=$(psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -tAc " - SELECT COUNT(*) - FROM pg_tables - WHERE schemaname IN ('auth', 'education', 'trading', 'investment', 'financial', 'ml', 'llm', 'audit') -") - -TOTAL_FUNCTIONS=$(psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -tAc " - SELECT COUNT(*) - FROM pg_proc p - JOIN pg_namespace n ON p.pronamespace = n.oid - WHERE n.nspname IN ('auth', 'education', 'trading', 'investment', 'financial', 'ml', 'llm', 'audit') -") - -TOTAL_ENUMS=$(psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -tAc " - SELECT COUNT(*) - FROM pg_type t - JOIN pg_namespace n ON t.typnamespace = n.oid - WHERE t.typtype = 'e' - AND n.nspname IN ('auth', 'education', 'trading', 'investment', 'financial', 'ml', 'llm', 'audit') -") - -echo "" -echo -e "${GREEN}============================================${NC}" -echo -e "${GREEN}Validation Summary${NC}" -echo -e "${GREEN}============================================${NC}" -echo -e "Total Tables: ${GREEN}$TOTAL_TABLES${NC}" -echo -e "Total Functions: ${GREEN}$TOTAL_FUNCTIONS${NC}" -echo -e "Total Enums: ${GREEN}$TOTAL_ENUMS${NC}" -echo "" -echo -e "${GREEN}✓ All DDL scripts validated successfully${NC}" -echo "" -echo -e "Master SQL file: ${BLUE}$MASTER_SQL${NC}" -echo -e "Error log: ${BLUE}$ERROR_LOG${NC}" diff --git a/apps/frontend/.env.example b/apps/frontend/.env.example deleted file mode 100644 index ae9e153..0000000 --- a/apps/frontend/.env.example +++ /dev/null @@ -1,16 +0,0 @@ -# API URLs -VITE_API_URL=http://localhost:3081 -VITE_LLM_URL=http://localhost:3085 -VITE_ML_URL=http://localhost:3083 -VITE_TRADING_URL=http://localhost:3086 - -# WebSocket URLs -VITE_WS_URL=ws://localhost:3081 - -# Feature Flags -VITE_ENABLE_PAPER_TRADING=true -VITE_ENABLE_REAL_TRADING=false - -# OAuth (if needed) -VITE_GOOGLE_CLIENT_ID= -VITE_GITHUB_CLIENT_ID= diff --git a/apps/frontend/.eslintrc.cjs b/apps/frontend/.eslintrc.cjs deleted file mode 100644 index 224b1f1..0000000 --- a/apps/frontend/.eslintrc.cjs +++ /dev/null @@ -1,40 +0,0 @@ -module.exports = { - root: true, - env: { - browser: true, - es2021: true, - }, - extends: [ - 'eslint:recommended', - 'plugin:react/recommended', - 'plugin:react-hooks/recommended', - 'plugin:@typescript-eslint/recommended', - ], - parser: '@typescript-eslint/parser', - parserOptions: { - ecmaVersion: 'latest', - sourceType: 'module', - ecmaFeatures: { - jsx: true, - }, - }, - plugins: ['react', 'react-hooks', '@typescript-eslint'], - settings: { - react: { - version: 'detect', - }, - }, - rules: { - 'react/react-in-jsx-scope': 'off', - 'react/prop-types': 'off', // Using TypeScript for prop validation - '@typescript-eslint/no-explicit-any': 'warn', - '@typescript-eslint/no-unused-vars': [ - 'warn', - { - argsIgnorePattern: '^_', - varsIgnorePattern: '^_', - }, - ], - 'react-hooks/exhaustive-deps': 'warn', - }, -}; diff --git a/apps/frontend/Dockerfile b/apps/frontend/Dockerfile deleted file mode 100644 index bc64511..0000000 --- a/apps/frontend/Dockerfile +++ /dev/null @@ -1,64 +0,0 @@ -# ============================================================================= -# OrbiQuant IA - Frontend Application -# Multi-stage Dockerfile for production deployment -# ============================================================================= - -# ----------------------------------------------------------------------------- -# Stage 1: Dependencies -# ----------------------------------------------------------------------------- -FROM node:20-alpine AS deps - -WORKDIR /app - -# Copy package files -COPY package*.json ./ - -# Install dependencies -RUN npm ci - -# ----------------------------------------------------------------------------- -# Stage 2: Builder -# ----------------------------------------------------------------------------- -FROM node:20-alpine AS builder - -WORKDIR /app - -# Build arguments for environment variables -ARG VITE_API_URL=http://localhost:3000 -ARG VITE_WS_URL=ws://localhost:3000 -ARG VITE_LLM_URL=http://localhost:8003 -ARG VITE_ML_URL=http://localhost:8001 - -# Set environment variables for build -ENV VITE_API_URL=$VITE_API_URL -ENV VITE_WS_URL=$VITE_WS_URL -ENV VITE_LLM_URL=$VITE_LLM_URL -ENV VITE_ML_URL=$VITE_ML_URL - -# Copy dependencies -COPY --from=deps /app/node_modules ./node_modules -COPY . . - -# Build the application -RUN npm run build - -# ----------------------------------------------------------------------------- -# Stage 3: Production (nginx) -# ----------------------------------------------------------------------------- -FROM nginx:alpine AS runner - -# Copy custom nginx config -COPY nginx.conf /etc/nginx/conf.d/default.conf - -# Copy built application -COPY --from=builder /app/dist /usr/share/nginx/html - -# Add health check -HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:80/health || exit 1 - -# Expose port -EXPOSE 80 - -# Start nginx -CMD ["nginx", "-g", "daemon off;"] diff --git a/apps/frontend/ML_DASHBOARD_IMPLEMENTATION.md b/apps/frontend/ML_DASHBOARD_IMPLEMENTATION.md deleted file mode 100644 index 604c31e..0000000 --- a/apps/frontend/ML_DASHBOARD_IMPLEMENTATION.md +++ /dev/null @@ -1,318 +0,0 @@ -# ML Dashboard - Implementación Completa - -## Resumen Ejecutivo - -Se ha implementado exitosamente un dashboard completo de predicciones ML para la plataforma de trading OrbiQuant IA. El módulo incluye visualizaciones avanzadas, métricas de performance y componentes reutilizables. - -## Archivos Creados - -### Componentes (`/src/modules/ml/components/`) - -1. **AMDPhaseIndicator.tsx** (212 líneas) - - Indicador visual de fases AMD (Accumulation/Manipulation/Distribution) - - Modo compacto y completo - - Muestra niveles clave y probabilidades de próxima fase - - Colores semánticos: Blue (Accumulation), Amber (Manipulation), Red (Distribution) - -2. **PredictionCard.tsx** (203 líneas) - - Tarjeta de señal ML individual - - Muestra Entry, Stop Loss, Take Profit - - Métricas: Confidence, R:R ratio, P(TP First) - - Estado de validez (activo/expirado) - - Botón para ejecutar trade - -3. **SignalsTimeline.tsx** (216 líneas) - - Timeline cronológica de señales - - Estados: pending, success, failed, expired - - Visualización de resultado P&L - - Diseño con línea de tiempo vertical - -4. **AccuracyMetrics.tsx** (202 líneas) - - Métricas de performance del modelo ML - - Overall accuracy, Win rate - - Sharpe ratio, Profit factor - - Best performing phase - - Visualización con barras de progreso - -5. **index.ts** (9 líneas) - - Barrel exports para importaciones limpias - -### Páginas (`/src/modules/ml/pages/`) - -1. **MLDashboard.tsx** (346 líneas) - - Dashboard principal con layout responsive - - Grid 3 columnas (desktop), 1 columna (mobile) - - Filtros por símbolo y estado - - Auto-refresh cada 60 segundos - - Integración completa con API ML Engine - - Manejo de errores y estados de carga - -### Documentación - -1. **README.md** - - Documentación completa del módulo - - Guía de uso de cada componente - - Estructura del proyecto - - Paleta de colores y estilos - -2. **ML_DASHBOARD_IMPLEMENTATION.md** (este archivo) - - Resumen de implementación - -## Archivos Modificados - -1. **App.tsx** - - Agregada ruta `/ml-dashboard` - - Lazy loading del componente MLDashboard - -2. **MLSignalsPanel.tsx** (módulo trading) - - Agregado link al dashboard ML completo - - Mejoras en visualización de métricas - - Más detalles de señales (Valid Until, métricas mejoradas) - -## Estructura de Directorios - -``` -apps/frontend/src/modules/ml/ -├── components/ -│ ├── AMDPhaseIndicator.tsx -│ ├── AccuracyMetrics.tsx -│ ├── PredictionCard.tsx -│ ├── SignalsTimeline.tsx -│ └── index.ts -├── pages/ -│ └── MLDashboard.tsx -└── README.md -``` - -## Características Implementadas - -### Dashboard Principal (MLDashboard) - -- Vista general de predicciones activas -- Filtros: - - Por símbolo (dropdown) - - Solo activas (checkbox) -- Indicador prominente de fase AMD -- Grid de señales activas -- Timeline de señales históricas -- Panel de métricas de accuracy -- Fases AMD por símbolo -- Quick stats (Avg Confidence, Avg R:R, Tracked Symbols) -- Auto-refresh cada 60 segundos -- Botón de refresh manual - -### Componentes Reutilizables - -#### AMDPhaseIndicator -- Versión completa con todos los detalles -- Versión compacta para cards -- Iconos visuales por fase -- Barras de probabilidad para próxima fase -- Niveles clave de soporte/resistencia - -#### PredictionCard -- Dirección de señal (LONG/SHORT) prominente -- Visualización de precios (Entry/SL/TP) -- Percentajes de potencial ganancia/pérdida -- Métricas: R:R, P(TP), Volatility -- Badge de confianza con colores -- Indicador de validez con timestamp -- Botón de ejecución de trade - -#### SignalsTimeline -- Diseño de timeline vertical -- Iconos de estado (success/failed/pending/expired) -- Información compacta de cada señal -- Time ago relativo -- Resultado P&L si disponible -- Soporte para paginación - -#### AccuracyMetrics -- Métricas principales destacadas -- Gráficos de barras de progreso -- Colores basados en thresholds -- Stats de señales (total/successful/failed) -- Métricas avanzadas (Sharpe, Profit Factor) -- Best performing phase destacado - -### Integración con API - -Consume los siguientes endpoints del ML Engine: - -``` -GET /api/v1/signals/active -GET /api/v1/signals/latest/:symbol -GET /api/v1/amd/detect/:symbol -GET /api/v1/predict/range/:symbol -POST /api/v1/signals/generate -``` - -### Diseño y UX - -#### Paleta de Colores (Tailwind) - -**Fases AMD:** -- Accumulation: `bg-blue-500`, `text-blue-400`, `border-blue-500` -- Manipulation: `bg-amber-500`, `text-amber-400`, `border-amber-500` -- Distribution: `bg-red-500`, `text-red-400`, `border-red-500` - -**Señales:** -- BUY/LONG: `bg-green-500`, `text-green-400` -- SELL/SHORT: `bg-red-500`, `text-red-400` - -**Niveles de Confianza:** -- Alta (≥70%): `text-green-400` -- Media (50-70%): `text-yellow-400` -- Baja (<50%): `text-red-400` - -#### Layout - -- Grid responsive: 1 col (mobile) → 3 cols (desktop) -- Cards con `rounded-lg`, `shadow-lg` -- Dark mode nativo -- Espaciado consistente (gap-4, gap-6) -- Transiciones suaves (`transition-colors`) - -#### Iconos (Heroicons) - -- SparklesIcon: ML/IA features -- ArrowTrendingUpIcon/DownIcon: Direcciones -- ChartBarIcon: Métricas -- ShieldCheckIcon: Risk/Reward -- ClockIcon: Tiempo/Validez -- TrophyIcon: Best performing -- FunnelIcon: Filtros - -### Navegación - -**Ruta principal:** -``` -/ml-dashboard -``` - -**Acceso desde:** -- Navegación principal (agregado en MainLayout) -- Link destacado en MLSignalsPanel (Trading page) - -## TypeScript Types - -Usa tipos del servicio ML: - -```typescript -interface MLSignal { - signal_id: string; - symbol: string; - direction: 'long' | 'short'; - entry_price: number; - stop_loss: number; - take_profit: number; - risk_reward_ratio: number; - confidence_score: number; - prob_tp_first: number; - amd_phase: string; - volatility_regime: string; - valid_until: string; - created_at: string; -} - -interface AMDPhase { - symbol: string; - phase: 'accumulation' | 'manipulation' | 'distribution' | 'unknown'; - confidence: number; - phase_duration_bars: number; - next_phase_probability: { - accumulation: number; - manipulation: number; - distribution: number; - }; - key_levels: { - support: number; - resistance: number; - }; -} -``` - -## Estado del Código - -- **Total de líneas nuevas:** ~1,179 líneas -- **Componentes:** 4 componentes + 1 página -- **TypeScript:** Strict mode, tipos completos -- **React Hooks:** useState, useEffect, useCallback -- **Error Handling:** Try/catch con mensajes user-friendly -- **Loading States:** Spinners y estados de carga -- **Responsive:** Mobile-first design - -## Testing Sugerido - -### Manual Testing -1. Navegar a `/ml-dashboard` -2. Verificar carga de señales activas -3. Probar filtros (por símbolo, active only) -4. Verificar auto-refresh (60s) -5. Hacer clic en botón de refresh manual -6. Verificar link "Open Full ML Dashboard" desde Trading page -7. Probar botón "Execute Trade" en PredictionCard -8. Verificar responsive en mobile/tablet/desktop - -### Unit Testing (TODO) -```bash -# Componentes a testear -- AMDPhaseIndicator rendering -- PredictionCard con diferentes estados -- SignalsTimeline con diferentes signals -- AccuracyMetrics con diferentes métricas -- MLDashboard filtros y estado -``` - -## Próximos Pasos - -### Mejoras Inmediatas -- [ ] Agregar endpoint real para accuracy metrics (actualmente usa mock) -- [ ] Implementar WebSocket para updates en tiempo real -- [ ] Agregar tests unitarios -- [ ] Agregar tests de integración - -### Mejoras Futuras -- [ ] Filtros avanzados (timeframe, volatility regime) -- [ ] Gráficos de performance histórica (Chart.js o Recharts) -- [ ] Exportar datos a CSV/PDF -- [ ] Notificaciones push para nuevas señales -- [ ] Comparación de múltiples modelos ML -- [ ] Backtesting visual integrado -- [ ] Configuración de alertas personalizadas -- [ ] Modo de análisis detallado por señal - -### Optimizaciones -- [ ] Memoización de componentes pesados -- [ ] Virtual scrolling para timeline larga -- [ ] Cache de datos ML -- [ ] Lazy loading de componentes - -## Notas de Desarrollo - -### Dependencias -- React 18+ -- React Router DOM 6+ -- TypeScript 5+ -- Tailwind CSS 3+ -- Heroicons 2+ - -### Convenciones de Código -- Functional components con hooks -- Props interfaces exportadas -- JSDoc comments en componentes principales -- Naming: PascalCase para componentes, camelCase para funciones - -### Performance -- Auto-refresh configurable (actualmente 60s) -- Lazy loading de página -- Optimización de re-renders con useCallback -- Limpieza de intervals en useEffect cleanup - -## Conclusión - -El dashboard ML está completamente implementado y listo para integración con el backend ML Engine. Todos los componentes son reutilizables, bien documentados y siguen las mejores prácticas de React y TypeScript. - -El diseño es consistente con el resto de la plataforma, usando Tailwind CSS y el theme dark existente. La UX es fluida con estados de carga, manejo de errores y feedback visual apropiado. - -**Estado: COMPLETO Y LISTO PARA PRODUCCIÓN** ✓ diff --git a/apps/frontend/eslint.config.js b/apps/frontend/eslint.config.js deleted file mode 100644 index b6d0882..0000000 --- a/apps/frontend/eslint.config.js +++ /dev/null @@ -1,46 +0,0 @@ -import eslint from '@eslint/js'; -import tseslint from 'typescript-eslint'; -import reactHooks from 'eslint-plugin-react-hooks'; -import reactRefresh from 'eslint-plugin-react-refresh'; -import globals from 'globals'; - -export default tseslint.config( - eslint.configs.recommended, - ...tseslint.configs.recommended, - { - ignores: ['dist/**', 'node_modules/**', 'coverage/**'], - }, - { - files: ['**/*.{ts,tsx}'], - languageOptions: { - ecmaVersion: 2022, - sourceType: 'module', - globals: { - ...globals.browser, - ...globals.es2020, - }, - parserOptions: { - ecmaFeatures: { - jsx: true, - }, - }, - }, - plugins: { - 'react-hooks': reactHooks, - 'react-refresh': reactRefresh, - }, - rules: { - ...reactHooks.configs.recommended.rules, - 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], - '@typescript-eslint/no-explicit-any': 'warn', - '@typescript-eslint/no-unused-vars': [ - 'warn', - { - argsIgnorePattern: '^_', - varsIgnorePattern: '^_', - caughtErrorsIgnorePattern: '^_', - }, - ], - }, - } -); diff --git a/apps/frontend/index.html b/apps/frontend/index.html deleted file mode 100644 index 73c9b0a..0000000 --- a/apps/frontend/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - OrbiQuant IA - - - - - - -
- - - diff --git a/apps/frontend/nginx.conf b/apps/frontend/nginx.conf deleted file mode 100644 index 6253e2a..0000000 --- a/apps/frontend/nginx.conf +++ /dev/null @@ -1,54 +0,0 @@ -server { - listen 80; - server_name localhost; - root /usr/share/nginx/html; - index index.html; - - # Gzip compression - gzip on; - gzip_vary on; - gzip_min_length 1024; - gzip_proxied expired no-cache no-store private auth; - gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript; - - # Security headers - add_header X-Frame-Options "SAMEORIGIN" always; - add_header X-Content-Type-Options "nosniff" always; - add_header X-XSS-Protection "1; mode=block" always; - - # Health check endpoint - location /health { - access_log off; - return 200 "healthy\n"; - add_header Content-Type text/plain; - } - - # Static assets with caching - location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { - expires 1y; - add_header Cache-Control "public, immutable"; - access_log off; - } - - # SPA routing - serve index.html for all routes - location / { - try_files $uri $uri/ /index.html; - } - - # API proxy (optional - if frontend needs to proxy API calls) - # location /api/ { - # proxy_pass http://backend:3000/; - # proxy_http_version 1.1; - # proxy_set_header Upgrade $http_upgrade; - # proxy_set_header Connection 'upgrade'; - # proxy_set_header Host $host; - # proxy_cache_bypass $http_upgrade; - # } - - # Error pages - error_page 404 /index.html; - error_page 500 502 503 504 /50x.html; - location = /50x.html { - root /usr/share/nginx/html; - } -} diff --git a/apps/frontend/package-lock.json b/apps/frontend/package-lock.json deleted file mode 100644 index f3516ae..0000000 --- a/apps/frontend/package-lock.json +++ /dev/null @@ -1,7144 +0,0 @@ -{ - "name": "@orbiquant/frontend", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@orbiquant/frontend", - "version": "0.1.0", - "dependencies": { - "@heroicons/react": "^2.2.0", - "@hookform/resolvers": "^3.3.2", - "@stripe/react-stripe-js": "^2.4.0", - "@stripe/stripe-js": "^2.2.1", - "@tanstack/react-query": "^5.14.0", - "@types/recharts": "^1.8.29", - "axios": "^1.6.2", - "clsx": "^2.0.0", - "date-fns": "^4.1.0", - "lightweight-charts": "^4.1.1", - "lucide-react": "^0.300.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-hook-form": "^7.49.2", - "react-hot-toast": "^2.4.1", - "react-router-dom": "^6.21.0", - "recharts": "^3.5.1", - "zod": "^3.22.4", - "zustand": "^4.4.7" - }, - "devDependencies": { - "@eslint/js": "^9.17.0", - "@testing-library/jest-dom": "^6.1.6", - "@testing-library/react": "^14.1.2", - "@types/react": "^18.2.45", - "@types/react-dom": "^18.2.18", - "@vitejs/plugin-react": "^4.2.1", - "@vitest/ui": "^3.0.0", - "autoprefixer": "^10.4.16", - "eslint": "^9.17.0", - "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-react-refresh": "^0.4.19", - "globals": "^15.14.0", - "jsdom": "^23.0.1", - "postcss": "^8.4.32", - "prettier": "^3.1.1", - "tailwindcss": "^3.4.0", - "typescript": "^5.3.3", - "typescript-eslint": "^8.18.0", - "vite": "^6.2.0", - "vitest": "^3.0.0" - } - }, - "node_modules/@adobe/css-tools": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", - "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", - "dev": true, - "license": "MIT" - }, - "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/@asamuzakjp/css-color": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", - "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@csstools/css-calc": "^2.1.3", - "@csstools/css-color-parser": "^3.0.9", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "lru-cache": "^10.4.3" - } - }, - "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/@asamuzakjp/dom-selector": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-2.0.2.tgz", - "integrity": "sha512-x1KXOatwofR6ZAYzXRBL5wrdV0vwNxlTCK9NCuLqAzQYARqGcvFwiJA6A1ERuh+dgeA4Dxm3JBYictIes+SqUQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "bidi-js": "^1.0.3", - "css-tree": "^2.3.1", - "is-potential-custom-element-name": "^1.0.1" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", - "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", - "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", - "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", - "@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.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "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.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", - "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.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.5" - }, - "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/runtime": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", - "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", - "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", - "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/@csstools/color-helpers": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", - "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=18" - } - }, - "node_modules/@csstools/css-calc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", - "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-color-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", - "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "dependencies": { - "@csstools/color-helpers": "^5.1.0", - "@csstools/css-calc": "^2.1.4" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-parser-algorithms": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", - "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-tokenizer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", - "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - } - }, - "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.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "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-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.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", - "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@eslint/js": { - "version": "9.39.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", - "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@heroicons/react": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz", - "integrity": "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==", - "license": "MIT", - "peerDependencies": { - "react": ">= 16 || ^19.0.0-rc" - } - }, - "node_modules/@hookform/resolvers": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz", - "integrity": "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==", - "license": "MIT", - "peerDependencies": { - "react-hook-form": "^7.0.0" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.4.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/@polka/url": { - "version": "1.0.0-next.29", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", - "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", - "dev": true, - "license": "MIT" - }, - "node_modules/@reduxjs/toolkit": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.1.tgz", - "integrity": "sha512-HjhlEREguAyBTGNzRlGNiDHGQ2EjLSPWwdhhpoEqHYy8hWak3Dp6/fU72OfqVsiMb8S6rbfPsWUF24fxpilrVA==", - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@standard-schema/utils": "^0.3.0", - "immer": "^11.0.0", - "redux": "^5.0.1", - "redux-thunk": "^3.1.0", - "reselect": "^5.1.0" - }, - "peerDependencies": { - "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", - "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-redux": { - "optional": true - } - } - }, - "node_modules/@reduxjs/toolkit/node_modules/immer": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.0.1.tgz", - "integrity": "sha512-naDCyggtcBWANtIrjQEajhhBEuL9b0Zg4zmlWK2CzS6xCWSE39/vvf4LqnMjUAWHBhot4m9MHCM/Z+mfWhUkiA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, - "node_modules/@remix-run/router": { - "version": "1.23.1", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.1.tgz", - "integrity": "sha512-vDbaOzF7yT2Qs4vO6XV1MHcJv+3dgR1sT+l3B8xxOVhUC336prMvqrvsLL/9Dnw2xr6Qhz4J0dmS0llNAbnUmQ==", - "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.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", - "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", - "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", - "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", - "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", - "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", - "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", - "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", - "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", - "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", - "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", - "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", - "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", - "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", - "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", - "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", - "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", - "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", - "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", - "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", - "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", - "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", - "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@standard-schema/spec": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", - "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", - "license": "MIT" - }, - "node_modules/@standard-schema/utils": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", - "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", - "license": "MIT" - }, - "node_modules/@stripe/react-stripe-js": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-2.9.0.tgz", - "integrity": "sha512-+/j2g6qKAKuWSurhgRMfdlIdKM+nVVJCy/wl0US2Ccodlqx0WqfIIBhUkeONkCG+V/b+bZzcj4QVa3E/rXtT4Q==", - "license": "MIT", - "dependencies": { - "prop-types": "^15.7.2" - }, - "peerDependencies": { - "@stripe/stripe-js": "^1.44.1 || ^2.0.0 || ^3.0.0 || ^4.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" - } - }, - "node_modules/@stripe/stripe-js": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-2.4.0.tgz", - "integrity": "sha512-WFkQx1mbs2b5+7looI9IV1BLa3bIApuN3ehp9FP58xGg7KL9hCHDECgW3BwO9l9L+xBPVAD7Yjn1EhGe6EDTeA==", - "license": "MIT", - "peer": true - }, - "node_modules/@tanstack/query-core": { - "version": "5.90.12", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.12.tgz", - "integrity": "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/react-query": { - "version": "5.90.12", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.12.tgz", - "integrity": "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==", - "license": "MIT", - "dependencies": { - "@tanstack/query-core": "5.90.12" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": "^18 || ^19" - } - }, - "node_modules/@testing-library/dom": { - "version": "9.3.4", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", - "integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.1.3", - "chalk": "^4.1.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@testing-library/dom/node_modules/aria-query": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", - "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "deep-equal": "^2.0.5" - } - }, - "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@testing-library/jest-dom": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", - "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@adobe/css-tools": "^4.4.0", - "aria-query": "^5.0.0", - "css.escape": "^1.5.1", - "dom-accessibility-api": "^0.6.3", - "picocolors": "^1.1.1", - "redent": "^3.0.0" - }, - "engines": { - "node": ">=14", - "npm": ">=6", - "yarn": ">=1" - } - }, - "node_modules/@testing-library/react": { - "version": "14.3.1", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.3.1.tgz", - "integrity": "sha512-H99XjUhWQw0lTgyMN05W3xQG1Nh4lq574D8keFf1dDoNTJgp66VbJozRaczoF+wsiaPJNt/TcnfpLGufGxSrZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5", - "@testing-library/dom": "^9.0.0", - "@types/react-dom": "^18.0.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0" - } - }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "license": "MIT" - }, - "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/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/d3-array": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", - "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", - "license": "MIT" - }, - "node_modules/@types/d3-color": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", - "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", - "license": "MIT" - }, - "node_modules/@types/d3-ease": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", - "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", - "license": "MIT" - }, - "node_modules/@types/d3-interpolate": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", - "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", - "license": "MIT", - "dependencies": { - "@types/d3-color": "*" - } - }, - "node_modules/@types/d3-path": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.11.tgz", - "integrity": "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==", - "license": "MIT" - }, - "node_modules/@types/d3-scale": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", - "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", - "license": "MIT", - "dependencies": { - "@types/d3-time": "*" - } - }, - "node_modules/@types/d3-shape": { - "version": "1.3.12", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.12.tgz", - "integrity": "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==", - "license": "MIT", - "dependencies": { - "@types/d3-path": "^1" - } - }, - "node_modules/@types/d3-time": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", - "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", - "license": "MIT" - }, - "node_modules/@types/d3-timer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", - "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", - "license": "MIT" - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "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/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "18.3.27", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", - "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", - "license": "MIT", - "peer": true, - "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==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^18.0.0" - } - }, - "node_modules/@types/recharts": { - "version": "1.8.29", - "resolved": "https://registry.npmjs.org/@types/recharts/-/recharts-1.8.29.tgz", - "integrity": "sha512-ulKklaVsnFIIhTQsQw226TnOibrddW1qUQNFVhoQEyY1Z7FRQrNecFCGt7msRuJseudzE9czVawZb17dK/aPXw==", - "license": "MIT", - "dependencies": { - "@types/d3-shape": "^1", - "@types/react": "*" - } - }, - "node_modules/@types/use-sync-external-store": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", - "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", - "license": "MIT" - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.49.0.tgz", - "integrity": "sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.49.0", - "@typescript-eslint/type-utils": "8.49.0", - "@typescript-eslint/utils": "8.49.0", - "@typescript-eslint/visitor-keys": "8.49.0", - "ignore": "^7.0.0", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.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.49.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/eslint-plugin/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/@typescript-eslint/parser": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.49.0.tgz", - "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@typescript-eslint/scope-manager": "8.49.0", - "@typescript-eslint/types": "8.49.0", - "@typescript-eslint/typescript-estree": "8.49.0", - "@typescript-eslint/visitor-keys": "8.49.0", - "debug": "^4.3.4" - }, - "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", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.49.0.tgz", - "integrity": "sha512-/wJN0/DKkmRUMXjZUXYZpD1NEQzQAAn9QWfGwo+Ai8gnzqH7tvqS7oNVdTjKqOcPyVIdZdyCMoqN66Ia789e7g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.49.0", - "@typescript-eslint/types": "^8.49.0", - "debug": "^4.3.4" - }, - "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.0.0" - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.49.0.tgz", - "integrity": "sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.49.0", - "@typescript-eslint/visitor-keys": "8.49.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/tsconfig-utils": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.49.0.tgz", - "integrity": "sha512-8prixNi1/6nawsRYxet4YOhnbW+W9FK/bQPxsGB1D3ZrDzbJ5FXw5XmzxZv82X3B+ZccuSxo/X8q9nQ+mFecWA==", - "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.0.0" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.49.0.tgz", - "integrity": "sha512-KTExJfQ+svY8I10P4HdxKzWsvtVnsuCifU5MvXrRwoP2KOlNZ9ADNEWWsQTJgMxLzS5VLQKDjkCT/YzgsnqmZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.49.0", - "@typescript-eslint/typescript-estree": "8.49.0", - "@typescript-eslint/utils": "8.49.0", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.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", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.49.0.tgz", - "integrity": "sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ==", - "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.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.49.0.tgz", - "integrity": "sha512-jrLdRuAbPfPIdYNppHJ/D0wN+wwNfJ32YTAm10eJVsFmrVpXQnDWBn8niCSMlWjvml8jsce5E/O+86IQtTbJWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.49.0", - "@typescript-eslint/tsconfig-utils": "8.49.0", - "@typescript-eslint/types": "8.49.0", - "@typescript-eslint/visitor-keys": "8.49.0", - "debug": "^4.3.4", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.1.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.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.49.0.tgz", - "integrity": "sha512-N3W7rJw7Rw+z1tRsHZbK395TWSYvufBXumYtEGzypgMUthlg0/hmCImeA8hgO2d2G4pd7ftpxxul2J8OdtdaFA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.49.0", - "@typescript-eslint/types": "8.49.0", - "@typescript-eslint/typescript-estree": "8.49.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", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.49.0.tgz", - "integrity": "sha512-LlKaciDe3GmZFphXIc79THF/YYBugZ7FS1pO581E/edlVVNbZKDy93evqmrfQ9/Y4uN0vVhX4iuchq26mK/iiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.49.0", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-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/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "3.2.4", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "3.2.4", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "magic-string": "^0.30.17", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/ui": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-3.2.4.tgz", - "integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@vitest/utils": "3.2.4", - "fflate": "^0.8.2", - "flatted": "^3.3.3", - "pathe": "^2.0.3", - "sirv": "^3.0.1", - "tinyglobby": "^0.2.14", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "vitest": "3.2.4" - } - }, - "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, - "license": "MIT", - "peer": true, - "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/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "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/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "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/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/aria-query": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", - "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", - "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "is-array-buffer": "^3.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/autoprefixer": { - "version": "10.4.22", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz", - "integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==", - "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.27.0", - "caniuse-lite": "^1.0.30001754", - "fraction.js": "^5.3.4", - "normalize-range": "^0.1.2", - "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/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", - "proxy-from-env": "^1.1.0" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/baseline-browser-mapping": { - "version": "2.9.5", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.5.tgz", - "integrity": "sha512-D5vIoztZOq1XM54LUdttJVc96ggEsIfju2JBvht06pSzpckp3C7HReun67Bghzrtdsq9XdMGbSSB3v3GhMNmAA==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.js" - } - }, - "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "require-from-string": "^2.0.2" - } - }, - "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": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "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.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", - "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", - "peer": true, - "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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==", - "dev": true, - "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/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "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.30001759", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001759.tgz", - "integrity": "sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==", - "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/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/check-error": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", - "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, - "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/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/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "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/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "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/css-tree": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", - "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mdn-data": "2.0.30", - "source-map-js": "^1.0.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, - "node_modules/css.escape": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", - "dev": true, - "license": "MIT" - }, - "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/cssstyle": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", - "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^3.2.0", - "rrweb-cssom": "^0.8.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/cssstyle/node_modules/rrweb-cssom": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", - "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", - "dev": true, - "license": "MIT" - }, - "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==", - "license": "MIT", - "peer": true - }, - "node_modules/d3-array": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", - "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", - "license": "ISC", - "dependencies": { - "internmap": "1 - 2" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-ease": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", - "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-format": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", - "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-interpolate": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", - "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", - "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-scale": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", - "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", - "license": "ISC", - "dependencies": { - "d3-array": "2.10.0 - 3", - "d3-format": "1 - 3", - "d3-interpolate": "1.2.0 - 3", - "d3-time": "2.1.1 - 3", - "d3-time-format": "2 - 4" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-shape": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", - "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", - "license": "ISC", - "dependencies": { - "d3-path": "^3.1.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", - "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", - "license": "ISC", - "dependencies": { - "d3-array": "2 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", - "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", - "license": "ISC", - "dependencies": { - "d3-time": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-timer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/data-urls": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", - "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/date-fns": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", - "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/kossnocorp" - } - }, - "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/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true, - "license": "MIT" - }, - "node_modules/decimal.js-light": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", - "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", - "license": "MIT" - }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/deep-equal": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", - "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "call-bind": "^1.0.5", - "es-get-iterator": "^1.1.3", - "get-intrinsic": "^1.2.2", - "is-arguments": "^1.1.1", - "is-array-buffer": "^3.0.2", - "is-date-object": "^1.0.5", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", - "isarray": "^2.0.5", - "object-is": "^1.1.5", - "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.5.1", - "side-channel": "^1.0.4", - "which-boxed-primitive": "^1.0.2", - "which-collection": "^1.0.1", - "which-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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/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/dom-accessibility-api": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", - "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", - "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.267", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", - "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", - "dev": true, - "license": "ISC" - }, - "node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-get-iterator": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", - "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.3", - "has-symbols": "^1.0.3", - "is-arguments": "^1.1.1", - "is-map": "^2.0.2", - "is-set": "^2.0.2", - "is-string": "^1.0.7", - "isarray": "^2.0.5", - "stop-iteration-iterator": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, - "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/es-toolkit": { - "version": "1.42.0", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.42.0.tgz", - "integrity": "sha512-SLHIyY7VfDJBM8clz4+T2oquwTQxEzu263AyhVK4jREOAwJ+8eebaa4wM3nlvnAqhDrMm2EsA6hWHaQsMPQ1nA==", - "license": "MIT", - "workspaces": [ - "docs", - "benchmarks" - ] - }, - "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": "9.39.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", - "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.1", - "@eslint/plugin-kit": "^0.4.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.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", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-plugin-react-hooks": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", - "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" - } - }, - "node_modules/eslint-plugin-react-refresh": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz", - "integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "eslint": ">=8.40" - } - }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", - "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/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.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/eventemitter3": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", - "license": "MIT" - }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/fancy-canvas": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fancy-canvas/-/fancy-canvas-2.1.0.tgz", - "integrity": "sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==", - "license": "MIT" - }, - "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.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fflate": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", - "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", - "dev": true, - "license": "MIT" - }, - "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.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", - "dev": true, - "license": "ISC" - }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "dev": true, - "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-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/globals": { - "version": "15.15.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", - "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/goober": { - "version": "2.1.18", - "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz", - "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==", - "license": "MIT", - "peerDependencies": { - "csstype": "^3.0.10" - } - }, - "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-bigints": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", - "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "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/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.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/html-encoding-sniffer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", - "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-encoding": "^3.1.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "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/immer": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", - "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", - "license": "MIT", - "peer": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/internal-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", - "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "hasown": "^2.0.2", - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/internmap": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", - "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/is-arguments": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", - "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-array-buffer": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", - "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-bigint": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", - "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-bigints": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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-boolean-object": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", - "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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-date-object": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", - "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.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-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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/is-number-object": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", - "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-set": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", - "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-string": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", - "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-symbol": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", - "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-symbols": "^1.1.0", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakmap": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakset": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", - "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, - "license": "MIT" - }, - "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", - "peer": true, - "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/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsdom": { - "version": "23.2.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-23.2.0.tgz", - "integrity": "sha512-L88oL7D/8ufIES+Zjz7v0aes+oBMh2Xnh3ygWvL0OaICOomKEPKuPnIfBJekiXr+BHbbMjrWn/xqrDQuxFTeyA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@asamuzakjp/dom-selector": "^2.0.1", - "cssstyle": "^4.0.1", - "data-urls": "^5.0.0", - "decimal.js": "^10.4.3", - "form-data": "^4.0.0", - "html-encoding-sniffer": "^4.0.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.2", - "is-potential-custom-element-name": "^1.0.1", - "parse5": "^7.1.2", - "rrweb-cssom": "^0.6.0", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^4.1.3", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^3.1.1", - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.0.0", - "ws": "^8.16.0", - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "canvas": "^2.11.2" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "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/lightweight-charts": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-4.2.3.tgz", - "integrity": "sha512-5kS/2hY3wNYNzhnS8Gb+GAS07DX8GPF2YVDnd2NMC85gJVQ6RLU6YrXNgNJ6eg0AnWPwCnvaGtYmGky3HiLQEw==", - "license": "Apache-2.0", - "dependencies": { - "fancy-canvas": "2.1.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/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, - "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/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, - "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.300.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.300.0.tgz", - "integrity": "sha512-rQxUUCmWAvNLoAsMZ5j04b2+OJv6UuNLYMY7VK0eVlm4aTwUEjEEHc09/DipkNIlhXUSDn2xoyIzVT0uh7dRsg==", - "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0" - } - }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, - "license": "MIT", - "bin": { - "lz-string": "bin/bin.js" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mdn-data": { - "version": "2.0.30", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", - "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", - "dev": true, - "license": "CC0-1.0" - }, - "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/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/mrmime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", - "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "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.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "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/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", - "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==", - "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/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-is": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", - "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.assign": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", - "has-symbols": "^1.1.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "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/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, - "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.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "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/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "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", - "peer": true, - "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/prettier": { - "version": "3.7.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", - "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/prop-types/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, - "node_modules/psl": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", - "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "funding": { - "url": "https://github.com/sponsors/lupomontero" - } - }, - "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/querystringify": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "dev": true, - "license": "MIT" - }, - "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", - "peer": true, - "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", - "peer": true, - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, - "node_modules/react-hook-form": { - "version": "7.68.0", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.68.0.tgz", - "integrity": "sha512-oNN3fjrZ/Xo40SWlHf1yCjlMK417JxoSJVUXQjGdvdRCU07NTFei1i1f8ApUAts+IVh14e4EdakeLEA+BEAs/Q==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/react-hook-form" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17 || ^18 || ^19" - } - }, - "node_modules/react-hot-toast": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", - "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==", - "license": "MIT", - "dependencies": { - "csstype": "^3.1.3", - "goober": "^2.1.16" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "react": ">=16", - "react-dom": ">=16" - } - }, - "node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "license": "MIT", - "peer": true - }, - "node_modules/react-redux": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", - "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/use-sync-external-store": "^0.0.6", - "use-sync-external-store": "^1.4.0" - }, - "peerDependencies": { - "@types/react": "^18.2.25 || ^19", - "react": "^18.0 || ^19", - "redux": "^5.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "redux": { - "optional": true - } - } - }, - "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-router": { - "version": "6.30.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.2.tgz", - "integrity": "sha512-H2Bm38Zu1bm8KUE5NVWRMzuIyAV8p/JrOaBJAwVmp37AXG72+CZJlEBw6pdn9i5TBgLMhNDgijS4ZlblpHyWTA==", - "license": "MIT", - "dependencies": { - "@remix-run/router": "1.23.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "react": ">=16.8" - } - }, - "node_modules/react-router-dom": { - "version": "6.30.2", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.2.tgz", - "integrity": "sha512-l2OwHn3UUnEVUqc6/1VMmR1cvZryZ3j3NzapC2eUXO1dB0sYp5mvwdjiXhpUbRb21eFow3qSxpP8Yv6oAU824Q==", - "license": "MIT", - "dependencies": { - "@remix-run/router": "1.23.1", - "react-router": "6.30.2" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" - } - }, - "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/recharts": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.5.1.tgz", - "integrity": "sha512-+v+HJojK7gnEgG6h+b2u7k8HH7FhyFUzAc4+cPrsjL4Otdgqr/ecXzAnHciqlzV1ko064eNcsdzrYOM78kankA==", - "license": "MIT", - "workspaces": [ - "www" - ], - "dependencies": { - "@reduxjs/toolkit": "1.x.x || 2.x.x", - "clsx": "^2.1.1", - "decimal.js-light": "^2.5.1", - "es-toolkit": "^1.39.3", - "eventemitter3": "^5.0.1", - "immer": "^10.1.1", - "react-redux": "8.x.x || 9.x.x", - "reselect": "5.1.1", - "tiny-invariant": "^1.3.3", - "use-sync-external-store": "^1.2.2", - "victory-vendor": "^37.0.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/redent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", - "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "indent-string": "^4.0.0", - "strip-indent": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/redux": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true - }, - "node_modules/redux-thunk": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", - "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", - "license": "MIT", - "peerDependencies": { - "redux": "^5.0.0" - } - }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", - "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-errors": "^1.3.0", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "set-function-name": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/reselect": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", - "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", - "license": "MIT" - }, - "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "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/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "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.53.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", - "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", - "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.53.3", - "@rollup/rollup-android-arm64": "4.53.3", - "@rollup/rollup-darwin-arm64": "4.53.3", - "@rollup/rollup-darwin-x64": "4.53.3", - "@rollup/rollup-freebsd-arm64": "4.53.3", - "@rollup/rollup-freebsd-x64": "4.53.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", - "@rollup/rollup-linux-arm-musleabihf": "4.53.3", - "@rollup/rollup-linux-arm64-gnu": "4.53.3", - "@rollup/rollup-linux-arm64-musl": "4.53.3", - "@rollup/rollup-linux-loong64-gnu": "4.53.3", - "@rollup/rollup-linux-ppc64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-musl": "4.53.3", - "@rollup/rollup-linux-s390x-gnu": "4.53.3", - "@rollup/rollup-linux-x64-gnu": "4.53.3", - "@rollup/rollup-linux-x64-musl": "4.53.3", - "@rollup/rollup-openharmony-arm64": "4.53.3", - "@rollup/rollup-win32-arm64-msvc": "4.53.3", - "@rollup/rollup-win32-ia32-msvc": "4.53.3", - "@rollup/rollup-win32-x64-gnu": "4.53.3", - "@rollup/rollup-win32-x64-msvc": "4.53.3", - "fsevents": "~2.3.2" - } - }, - "node_modules/rrweb-cssom": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", - "integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==", - "dev": true, - "license": "MIT" - }, - "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/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT" - }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, - "license": "ISC", - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, - "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": "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/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-function-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "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/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==", - "dev": true, - "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==", - "dev": true, - "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==", - "dev": true, - "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==", - "dev": true, - "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/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/sirv": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", - "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@polka/url": "^1.0.0-next.24", - "mrmime": "^2.0.0", - "totalist": "^3.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "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/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/stop-iteration-iterator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", - "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "internal-slot": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/strip-indent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", - "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "min-indent": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/strip-literal/node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, - "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-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "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/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, - "license": "MIT" - }, - "node_modules/tailwindcss": { - "version": "3.4.18", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", - "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", - "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/tiny-invariant": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", - "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", - "license": "MIT" - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "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.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "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/totalist": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", - "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/tough-cookie": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", - "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.2.0", - "url-parse": "^1.5.3" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/tr46": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", - "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", - "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/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", - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/typescript-eslint": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.49.0.tgz", - "integrity": "sha512-zRSVH1WXD0uXczCXw+nsdjGPUdx4dfrs5VQoHnUWmv1U3oNlAKv4FUNdLDhVUg+gYn+a5hUESqch//Rv5wVhrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.49.0", - "@typescript-eslint/parser": "8.49.0", - "@typescript-eslint/typescript-estree": "8.49.0", - "@typescript-eslint/utils": "8.49.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", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/universalify": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz", - "integrity": "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==", - "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/url-parse": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", - "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "querystringify": "^2.1.1", - "requires-port": "^1.0.0" - } - }, - "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "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/victory-vendor": { - "version": "37.3.6", - "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", - "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", - "license": "MIT AND ISC", - "dependencies": { - "@types/d3-array": "^3.0.3", - "@types/d3-ease": "^3.0.0", - "@types/d3-interpolate": "^3.0.1", - "@types/d3-scale": "^4.0.2", - "@types/d3-shape": "^3.1.0", - "@types/d3-time": "^3.0.0", - "@types/d3-timer": "^3.0.0", - "d3-array": "^3.1.6", - "d3-ease": "^3.0.1", - "d3-interpolate": "^3.0.1", - "d3-scale": "^4.0.2", - "d3-shape": "^3.1.0", - "d3-time": "^3.0.0", - "d3-timer": "^3.0.1" - } - }, - "node_modules/victory-vendor/node_modules/@types/d3-shape": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", - "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", - "license": "MIT", - "dependencies": { - "@types/d3-path": "*" - } - }, - "node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", - "dev": true, - "license": "MIT", - "peer": true, - "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": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "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.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", - "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/debug": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-encoding": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", - "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "iconv-lite": "0.6.3" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/whatwg-mimetype": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", - "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/whatwg-url": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", - "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tr46": "^5.1.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "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/which-boxed-primitive": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", - "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-bigint": "^1.1.0", - "is-boolean-object": "^1.2.1", - "is-number-object": "^1.1.1", - "is-string": "^1.1.1", - "is-symbol": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-collection": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", - "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-map": "^2.0.3", - "is-set": "^2.0.3", - "is-weakmap": "^2.0.2", - "is-weakset": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-typed-array": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", - "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", - "dev": true, - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "for-each": "^0.3.5", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "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/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/xml-name-validator": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", - "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/xmlchars": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, - "license": "MIT" - }, - "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": "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" - } - }, - "node_modules/zustand": { - "version": "4.5.7", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", - "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", - "license": "MIT", - "dependencies": { - "use-sync-external-store": "^1.2.2" - }, - "engines": { - "node": ">=12.7.0" - }, - "peerDependencies": { - "@types/react": ">=16.8", - "immer": ">=9.0.6", - "react": ">=16.8" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - } - } - } - } -} diff --git a/apps/frontend/package.json b/apps/frontend/package.json deleted file mode 100644 index c73a18a..0000000 --- a/apps/frontend/package.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "name": "@orbiquant/frontend", - "version": "0.1.0", - "type": "module", - "description": "OrbiQuant IA - Frontend Application", - "scripts": { - "dev": "vite", - "build": "tsc && vite build", - "preview": "vite preview", - "lint": "eslint src", - "lint:fix": "eslint src --fix", - "format": "prettier --write \"src/**/*.{ts,tsx,css}\"", - "test": "vitest", - "test:ui": "vitest --ui", - "test:coverage": "vitest --coverage", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@heroicons/react": "^2.2.0", - "@hookform/resolvers": "^3.3.2", - "@stripe/react-stripe-js": "^2.4.0", - "@stripe/stripe-js": "^2.2.1", - "@tanstack/react-query": "^5.14.0", - "@types/recharts": "^1.8.29", - "axios": "^1.6.2", - "clsx": "^2.0.0", - "date-fns": "^4.1.0", - "lightweight-charts": "^4.1.1", - "lucide-react": "^0.300.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-hook-form": "^7.49.2", - "react-hot-toast": "^2.4.1", - "react-router-dom": "^6.21.0", - "recharts": "^3.5.1", - "zod": "^3.22.4", - "zustand": "^4.4.7" - }, - "devDependencies": { - "@eslint/js": "^9.17.0", - "@testing-library/jest-dom": "^6.1.6", - "@testing-library/react": "^14.1.2", - "@types/react": "^18.2.45", - "@types/react-dom": "^18.2.18", - "@vitejs/plugin-react": "^4.2.1", - "@vitest/ui": "^3.0.0", - "autoprefixer": "^10.4.16", - "eslint": "^9.17.0", - "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-react-refresh": "^0.4.19", - "globals": "^15.14.0", - "jsdom": "^23.0.1", - "postcss": "^8.4.32", - "prettier": "^3.1.1", - "tailwindcss": "^3.4.0", - "typescript": "^5.3.3", - "typescript-eslint": "^8.18.0", - "vite": "^6.2.0", - "vitest": "^3.0.0" - } -} diff --git a/apps/frontend/postcss.config.js b/apps/frontend/postcss.config.js deleted file mode 100644 index 2e7af2b..0000000 --- a/apps/frontend/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -} diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx deleted file mode 100644 index a8de66a..0000000 --- a/apps/frontend/src/App.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { Routes, Route, Navigate } from 'react-router-dom'; -import { Suspense, lazy } from 'react'; - -// Layout -import MainLayout from './components/layout/MainLayout'; -import AuthLayout from './components/layout/AuthLayout'; - -// Loading component -const LoadingSpinner = () => ( -
-
-
-); - -// Lazy load modules -const Login = lazy(() => import('./modules/auth/pages/Login')); -const Register = lazy(() => import('./modules/auth/pages/Register')); -const ForgotPassword = lazy(() => import('./modules/auth/pages/ForgotPassword')); -const AuthCallback = lazy(() => import('./modules/auth/pages/AuthCallback')); -const VerifyEmail = lazy(() => import('./modules/auth/pages/VerifyEmail')); -const ResetPassword = lazy(() => import('./modules/auth/pages/ResetPassword')); - -const Dashboard = lazy(() => import('./modules/dashboard/pages/Dashboard')); -const Trading = lazy(() => import('./modules/trading/pages/Trading')); -const MLDashboard = lazy(() => import('./modules/ml/pages/MLDashboard')); -const BacktestingDashboard = lazy(() => import('./modules/backtesting/pages/BacktestingDashboard')); -const Courses = lazy(() => import('./modules/education/pages/Courses')); -const CourseDetail = lazy(() => import('./modules/education/pages/CourseDetail')); -const Investment = lazy(() => import('./modules/investment/pages/Investment')); -const Settings = lazy(() => import('./modules/settings/pages/Settings')); -const Assistant = lazy(() => import('./modules/assistant/pages/Assistant')); - -// Admin module (lazy loaded) -const AdminDashboard = lazy(() => import('./modules/admin/pages/AdminDashboard')); -const MLModelsPage = lazy(() => import('./modules/admin/pages/MLModelsPage')); -const AgentsPage = lazy(() => import('./modules/admin/pages/AgentsPage')); -const PredictionsPage = lazy(() => import('./modules/admin/pages/PredictionsPage')); - -function App() { - return ( - }> - - {/* Auth routes */} - }> - } /> - } /> - } /> - } /> - } /> - - - {/* OAuth callback (no layout) */} - } /> - - {/* Protected routes */} - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - {/* Redirects */} - } /> - } /> - - - ); -} - -export default App; diff --git a/apps/frontend/src/__tests__/mlService.test.ts b/apps/frontend/src/__tests__/mlService.test.ts deleted file mode 100644 index b42af79..0000000 --- a/apps/frontend/src/__tests__/mlService.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * ML Service Tests - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -// Mock fetch globally -const mockFetch = vi.fn(); -vi.stubGlobal('fetch', mockFetch); - -describe('ML Service', () => { - beforeEach(() => { - mockFetch.mockClear(); - }); - - describe('getICTAnalysis', () => { - it('should fetch ICT analysis for a symbol', async () => { - const mockAnalysis = { - symbol: 'EURUSD', - timeframe: '1H', - market_bias: 'bullish', - bias_confidence: 0.75, - score: 72, - order_blocks: [], - fair_value_gaps: [], - signals: ['bullish_ob_fresh'], - }; - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mockAnalysis, - }); - - // Dynamic import to get mocked version - const { getICTAnalysis } = await import('../services/mlService'); - const result = await getICTAnalysis('EURUSD', '1H'); - - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining('/api/ict/EURUSD'), - expect.objectContaining({ method: 'POST' }) - ); - expect(result).toEqual(mockAnalysis); - }); - - it('should return null on 404', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 404, - }); - - const { getICTAnalysis } = await import('../services/mlService'); - const result = await getICTAnalysis('INVALID'); - expect(result).toBeNull(); - }); - - it('should return null on error', async () => { - mockFetch.mockRejectedValueOnce(new Error('Network error')); - - const { getICTAnalysis } = await import('../services/mlService'); - const result = await getICTAnalysis('EURUSD'); - expect(result).toBeNull(); - }); - }); - - describe('checkHealth', () => { - it('should return true when healthy', async () => { - mockFetch.mockResolvedValueOnce({ ok: true }); - - const { checkHealth } = await import('../services/mlService'); - const result = await checkHealth(); - expect(result).toBe(true); - }); - - it('should return false when unhealthy', async () => { - mockFetch.mockResolvedValueOnce({ ok: false }); - - const { checkHealth } = await import('../services/mlService'); - const result = await checkHealth(); - expect(result).toBe(false); - }); - - it('should return false on error', async () => { - mockFetch.mockRejectedValueOnce(new Error('Network error')); - - const { checkHealth } = await import('../services/mlService'); - const result = await checkHealth(); - expect(result).toBe(false); - }); - }); -}); diff --git a/apps/frontend/src/__tests__/tradingService.test.ts b/apps/frontend/src/__tests__/tradingService.test.ts deleted file mode 100644 index 83a929c..0000000 --- a/apps/frontend/src/__tests__/tradingService.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -/** - * Trading Service Tests - ML Trade Execution - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -// Mock fetch globally -const mockFetch = vi.fn(); -vi.stubGlobal('fetch', mockFetch); - -// Mock localStorage -const mockLocalStorage = { - getItem: vi.fn(() => 'test-token'), - setItem: vi.fn(), - removeItem: vi.fn(), - clear: vi.fn(), - key: vi.fn(), - length: 0, -}; -vi.stubGlobal('localStorage', mockLocalStorage); - -describe('Trading Service - ML Execution', () => { - beforeEach(() => { - mockFetch.mockClear(); - mockLocalStorage.getItem.mockReturnValue('test-token'); - }); - - describe('executeMLTrade', () => { - it('should execute a trade successfully', async () => { - const mockResult = { - success: true, - trade_id: 'trade-123', - order_id: 'order-456', - executed_price: 1.08500, - lot_size: 0.1, - message: 'Trade executed successfully', - }; - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mockResult, - }); - - const { executeMLTrade } = await import('../services/trading.service'); - const result = await executeMLTrade({ - symbol: 'EURUSD', - direction: 'buy', - source: 'ict', - entry_price: 1.085, - stop_loss: 1.08, - take_profit: 1.095, - lot_size: 0.1, - }); - - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining('/api/trade/execute'), - expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ - 'Authorization': 'Bearer test-token', - }), - }) - ); - expect(result.success).toBe(true); - expect(result.executed_price).toBe(1.085); - }); - - it('should handle trade execution failure', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - json: async () => ({ detail: 'Insufficient margin' }), - }); - - const { executeMLTrade } = await import('../services/trading.service'); - const result = await executeMLTrade({ - symbol: 'EURUSD', - direction: 'buy', - source: 'ensemble', - lot_size: 10, - }); - - expect(result.success).toBe(false); - expect(result.error).toBe('Insufficient margin'); - }); - }); - - describe('getMT4Account', () => { - it('should fetch MT4 account info', async () => { - const mockAccount = { - account_id: '12345', - broker: 'Demo Broker', - balance: 10000, - equity: 10500, - connected: true, - }; - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => mockAccount, - }); - - const { getMT4Account } = await import('../services/trading.service'); - const result = await getMT4Account(); - - expect(result).toEqual(mockAccount); - expect(result?.connected).toBe(true); - }); - - it('should return null when not connected', async () => { - mockFetch.mockResolvedValueOnce({ ok: false }); - - const { getMT4Account } = await import('../services/trading.service'); - const result = await getMT4Account(); - expect(result).toBeNull(); - }); - }); - - describe('getLLMAgentHealth', () => { - it('should return true when healthy', async () => { - mockFetch.mockResolvedValueOnce({ ok: true }); - - const { getLLMAgentHealth } = await import('../services/trading.service'); - const result = await getLLMAgentHealth(); - expect(result).toBe(true); - }); - - it('should return false on error', async () => { - mockFetch.mockRejectedValueOnce(new Error('Connection refused')); - - const { getLLMAgentHealth } = await import('../services/trading.service'); - const result = await getLLMAgentHealth(); - expect(result).toBe(false); - }); - }); -}); diff --git a/apps/frontend/src/components/chat/ChatInput.tsx b/apps/frontend/src/components/chat/ChatInput.tsx deleted file mode 100644 index 18c8edf..0000000 --- a/apps/frontend/src/components/chat/ChatInput.tsx +++ /dev/null @@ -1,149 +0,0 @@ -/** - * ChatInput Component - * Expandable textarea for sending messages - */ - -import React, { useState, useRef, useEffect, KeyboardEvent } from 'react'; -import { Send, Loader2 } from 'lucide-react'; - -interface ChatInputProps { - onSendMessage: (message: string) => void; - disabled?: boolean; - loading?: boolean; - placeholder?: string; -} - -export const ChatInput: React.FC = ({ - onSendMessage, - disabled = false, - loading = false, - placeholder = 'Ask me anything about trading...', -}) => { - const [message, setMessage] = useState(''); - const textareaRef = useRef(null); - - // Auto-resize textarea - useEffect(() => { - const textarea = textareaRef.current; - if (textarea) { - textarea.style.height = 'auto'; - textarea.style.height = `${Math.min(textarea.scrollHeight, 150)}px`; - } - }, [message]); - - // Focus on mount - useEffect(() => { - if (!disabled && !loading) { - textareaRef.current?.focus(); - } - }, [disabled, loading]); - - const handleSubmit = () => { - const trimmedMessage = message.trim(); - if (!trimmedMessage || disabled || loading) return; - - onSendMessage(trimmedMessage); - setMessage(''); - - // Reset textarea height - if (textareaRef.current) { - textareaRef.current.style.height = 'auto'; - } - }; - - const handleKeyDown = (e: KeyboardEvent) => { - // Submit on Enter (without Shift) - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSubmit(); - } - }; - - const isDisabled = disabled || loading; - - return ( -
-
- {/* Textarea */} -
-