commit a8a48a8436b06cc1c0128b795b88ffc8dd51ea11 Author: dtookey Date: Thu Jun 12 16:57:42 2025 -0400 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0311c84 --- /dev/null +++ b/.gitignore @@ -0,0 +1,56 @@ +# IDE files +/vibeStonk.iml +.idea/ +.vscode/ +*.swp +*.swo + +# Go +/bin/ +/pkg/ +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out +go.work + +# Generated protobuf files +/proto/gen/ + +# Node.js +/client/node_modules/ +/client/.pnp +/client/.pnp.js +/client/package-lock.json + +# Build artifacts +/client/dist/ +/client/dist-ssr/ +/client/build/ +/client/coverage/ + +# Environment files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* +logs +*.log + +# OS specific +.DS_Store +Thumbs.db +/dist/ +/server/models/ +/client/src/types/generated/ \ No newline at end of file diff --git a/.junie/guidelines.md b/.junie/guidelines.md new file mode 100644 index 0000000..abc422e --- /dev/null +++ b/.junie/guidelines.md @@ -0,0 +1,214 @@ +# VibeStonk Development Guidelines + +This document provides essential information for developers working on the VibeStonk project. + +## Build/Configuration Instructions + +### Prerequisites +- Go 1.23.0 or later +- Node.js and npm (for client development) +- Protocol Buffers compiler (protoc) +- Go Plugin for Protocol Buffers compiler + +### Project Structure +- `client/`: Frontend code +- `proto/`: Protocol Buffer definitions +- `server/`: Backend Go code +- `scripts/`: Build and utility scripts +- `dist/`: Build artifacts +- `kubes/`: Kubernetes deployment configurations + +### Building the Project +The project can be built using the provided build script: + +```bash +./scripts/build.sh +``` + +This script: +1. Cleans the dist directory +2. Generates Go code from Protocol Buffer definitions +3. Builds the Go server +4. Builds the client using npm +5. Copies client build artifacts to the dist/content directory + +### Docker Build +For containerized deployment, use the Docker build script: + +```bash +./scripts/build_docker.sh +``` + +This builds Docker images for both the API server and static content server. + +### Protocol Buffer Generation +Protocol Buffer code is generated using: + +```bash +./scripts/generate_proto.sh +``` + +This script: +1. Removes old generated files +2. Creates directories for TypeScript definitions +3. Generates Go code from Protocol Buffer definitions +4. Generates TypeScript definitions for client-side use + +### Configuration +The server uses a configuration system defined in `server/repository/config.go`. The default configuration can be obtained using `repository.GetDefaultConfig()`, which sets up: +- Database engine (SQLite) +- Data directories +- Server listen address (0.0.0.0:8080) + +## Testing Information + +### Test Configuration +Tests use a separate configuration obtained via `repository.GetTestConfigs()`, which: +- Uses the "test-data/" directory for test databases +- Creates separate test databases for each supported database engine + +### Running Tests +Tests can be run using the standard Go test command: + +```bash +# Run all tests in a package +cd server/util && go test + +# Run tests with verbose output +cd server/util && go test -v + +# Run a specific test +cd server/util && go test -v -run TestReverseString +``` + +### Test Cleanup +After running tests, you can clean up test databases using: + +```bash +./scripts/remove_test_dbs.sh +``` + +### Creating Tests +Tests follow the standard Go testing pattern: + +1. Create a file with the `_test.go` suffix +2. Import the `testing` package +3. Create test functions with the `Test` prefix + +Below are examples of tests in this project. Note that these are simplified examples for illustration purposes. + +**Example of a simple utility test using table-driven testing:** + +``` +// In file: server/util/string_utils_test.go +package util + +import ( + "testing" +) + +// TestReverseString tests the ReverseString function +func TestReverseString(t *testing.T) { + // Define test cases with input and expected output + testCases := []struct { + name string + input string + expected string + }{ + {"empty string", "", ""}, + {"normal string", "hello", "olleh"}, + } + + // Run each test case + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := ReverseString(tc.input) + if result != tc.expected { + t.Errorf("Expected %q, got %q", tc.expected, result) + } + }) + } +} +``` + +**Example of a service test:** + +``` +// In file: server/services/authService_test.go +package services + +import ( + "testing" + "vibeStonk/server/repository" + "vibeStonk/server/util" +) + +// TestAuthService_RegisterUser tests user registration +func TestAuthService_RegisterUser(t *testing.T) { + // Get test configurations and credentials + configs := repository.GetTestConfigs() + testUserName, testUserPassword := util.GetTestUserCredentials() + + // Test with each configuration + for _, config := range configs { + // Create service + service, err := NewAuthService(config) + if err != nil { + t.Errorf("error creating service: %v", err) + } + + // Register user + user, err := service.RegisterUser(testUserName, "Test", testUserPassword) + if err != nil { + t.Errorf("expected no error, got: %+v", err) + } + + // Verify user properties + if user.UserName != testUserName || user.PrefName != "Test" { + t.Fail() + } + } +} +``` + +### Test Utilities +The project provides test utilities in: +- `server/util/testDB.go`: Provides test user credentials +- `server/repository/config.go`: Provides test configurations + +## Additional Development Information + +### Code Organization +- **Routes**: Defined in `server/routes/`, using the Echo framework +- **Services**: Business logic in `server/services/` +- **Repository**: Data access in `server/repository/` +- **Models**: Generated from Protocol Buffers in `server/models/` +- **Utilities**: Helper functions in `server/util/` + +### API Server +The API server is created using `routes.NewAPIServer(config)` and routes are added using the `AddRoute` or `AddRoutesBulk` methods. + +### Authentication +The project uses a token-based authentication system: +- Tokens are provided in the "Bearer" header +- Authentication is handled by the `AuthenticationMiddleware` +- User registration and authentication are managed by the `AuthService` + +### Database +The project uses SQLite for data storage: +- Database files are stored in the "data/" directory +- Test databases are stored in the "test-data/" directory +- The database engine is configurable in the `Config` struct + +### Error Handling +Errors are propagated up the call stack and wrapped with context using `fmt.Errorf("context: %w", err)`. + +### Generics +The project uses Go generics for type-safe utility functions, as seen in `server/util/func.go` and `server/util/string_utils.go`. + +### Kubernetes Deployment +The project includes Kubernetes deployment configurations in the `kubes/` directory: +- Namespace configuration +- Deployment configurations for API and static content servers +- Service configurations +- Ingress configuration for routing external traffic diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..392e0e5 --- /dev/null +++ b/client/index.html @@ -0,0 +1,12 @@ + + + + + + VibeStonk + + +
+ + + \ No newline at end of file diff --git a/client/package-lock.json b/client/package-lock.json new file mode 100644 index 0000000..c829218 --- /dev/null +++ b/client/package-lock.json @@ -0,0 +1,3888 @@ +{ + "name": "vibestonk-client", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vibestonk-client", + "version": "0.1.0", + "dependencies": { + "axios": "^1.6.2", + "google-protobuf": "^3.21.4", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@changyenh/protoc-gen-ts": "^0.8.3", + "@types/google-protobuf": "^3.15.12", + "@types/react": "^18.2.37", + "@types/react-dom": "^18.2.15", + "@typescript-eslint/eslint-plugin": "^6.10.0", + "@typescript-eslint/parser": "^6.10.0", + "@vitejs/plugin-react": "^4.2.0", + "eslint": "^8.53.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.4", + "typescript": "^5.2.2", + "vite": "^6.3.5" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.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.27.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.27.5.tgz", + "integrity": "sha512-KiRAp/VoJaWkkte84TvUd9qjdbZAdiqyvMxrGl1N6vzFogKmaLgoM3L1kgtLicp2HP5fBJS8JrZKLVIZGVJAVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.27.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.4.tgz", + "integrity": "sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.27.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.27.3", + "@babel/helpers": "^7.27.4", + "@babel/parser": "^7.27.4", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.27.4", + "@babel/types": "^7.27.3", + "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.27.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.5.tgz", + "integrity": "sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.27.5", + "@babel/types": "^7.27.3", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "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-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.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", + "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.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.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "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.27.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz", + "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.27.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.5.tgz", + "integrity": "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.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.27.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.4.tgz", + "integrity": "sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.27.3", + "@babel/parser": "^7.27.4", + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.3", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.6.tgz", + "integrity": "sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@changyenh/protoc-gen-ts": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@changyenh/protoc-gen-ts/-/protoc-gen-ts-0.8.3.tgz", + "integrity": "sha512-IOWnGjLpctN2BIJyf+o9txEB6Rl5A8lInR/w52/aYbvW03BEgOPeCGdeWL28LW9GxD4yMzzVC73FKVNoV7Yjng==", + "dev": true, + "license": "MIT", + "bin": { + "protoc-gen-ts": "bin/protoc-gen-ts.js" + }, + "funding": { + "type": "individual", + "url": "https://www.buymeacoffee.com/thesayyn" + }, + "peerDependencies": { + "google-protobuf": "^3.13.0", + "typescript": ">=4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", + "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz", + "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz", + "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz", + "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", + "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz", + "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz", + "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz", + "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz", + "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz", + "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz", + "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz", + "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz", + "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz", + "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz", + "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz", + "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", + "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz", + "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz", + "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz", + "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz", + "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz", + "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz", + "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz", + "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz", + "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "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.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "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": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/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/@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/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", + "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "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/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "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/@rolldown/pluginutils": { + "version": "1.0.0-beta.11", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.11.tgz", + "integrity": "sha512-L/gAA/hyCSuzTF1ftlzUSI/IKr2POHsv1Dd78GfqkR83KMNuswWD61JxGV2L7nRwBBBSDr6R1gCkdTmoN7W4ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.42.0.tgz", + "integrity": "sha512-gldmAyS9hpj+H6LpRNlcjQWbuKUtb94lodB9uCz71Jm+7BxK1VIOo7y62tZZwxhA7j1ylv/yQz080L5WkS+LoQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.42.0.tgz", + "integrity": "sha512-bpRipfTgmGFdCZDFLRvIkSNO1/3RGS74aWkJJTFJBH7h3MRV4UijkaEUeOMbi9wxtxYmtAbVcnMtHTPBhLEkaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.42.0.tgz", + "integrity": "sha512-JxHtA081izPBVCHLKnl6GEA0w3920mlJPLh89NojpU2GsBSB6ypu4erFg/Wx1qbpUbepn0jY4dVWMGZM8gplgA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.42.0.tgz", + "integrity": "sha512-rv5UZaWVIJTDMyQ3dCEK+m0SAn6G7H3PRc2AZmExvbDvtaDc+qXkei0knQWcI3+c9tEs7iL/4I4pTQoPbNL2SA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.42.0.tgz", + "integrity": "sha512-fJcN4uSGPWdpVmvLuMtALUFwCHgb2XiQjuECkHT3lWLZhSQ3MBQ9pq+WoWeJq2PrNxr9rPM1Qx+IjyGj8/c6zQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.42.0.tgz", + "integrity": "sha512-CziHfyzpp8hJpCVE/ZdTizw58gr+m7Y2Xq5VOuCSrZR++th2xWAz4Nqk52MoIIrV3JHtVBhbBsJcAxs6NammOQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.42.0.tgz", + "integrity": "sha512-UsQD5fyLWm2Fe5CDM7VPYAo+UC7+2Px4Y+N3AcPh/LdZu23YcuGPegQly++XEVaC8XUTFVPscl5y5Cl1twEI4A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.42.0.tgz", + "integrity": "sha512-/i8NIrlgc/+4n1lnoWl1zgH7Uo0XK5xK3EDqVTf38KvyYgCU/Rm04+o1VvvzJZnVS5/cWSd07owkzcVasgfIkQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.42.0.tgz", + "integrity": "sha512-eoujJFOvoIBjZEi9hJnXAbWg+Vo1Ov8n/0IKZZcPZ7JhBzxh2A+2NFyeMZIRkY9iwBvSjloKgcvnjTbGKHE44Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.42.0.tgz", + "integrity": "sha512-/3NrcOWFSR7RQUQIuZQChLND36aTU9IYE4j+TB40VU78S+RA0IiqHR30oSh6P1S9f9/wVOenHQnacs/Byb824g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.42.0.tgz", + "integrity": "sha512-O8AplvIeavK5ABmZlKBq9/STdZlnQo7Sle0LLhVA7QT+CiGpNVe197/t8Aph9bhJqbDVGCHpY2i7QyfEDDStDg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.42.0.tgz", + "integrity": "sha512-6Qb66tbKVN7VyQrekhEzbHRxXXFFD8QKiFAwX5v9Xt6FiJ3BnCVBuyBxa2fkFGqxOCSGGYNejxd8ht+q5SnmtA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.42.0.tgz", + "integrity": "sha512-KQETDSEBamQFvg/d8jajtRwLNBlGc3aKpaGiP/LvEbnmVUKlFta1vqJqTrvPtsYsfbE/DLg5CC9zyXRX3fnBiA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.42.0.tgz", + "integrity": "sha512-qMvnyjcU37sCo/tuC+JqeDKSuukGAd+pVlRl/oyDbkvPJ3awk6G6ua7tyum02O3lI+fio+eM5wsVd66X0jQtxw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.42.0.tgz", + "integrity": "sha512-I2Y1ZUgTgU2RLddUHXTIgyrdOwljjkmcZ/VilvaEumtS3Fkuhbw4p4hgHc39Ypwvo2o7sBFNl2MquNvGCa55Iw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.42.0.tgz", + "integrity": "sha512-Gfm6cV6mj3hCUY8TqWa63DB8Mx3NADoFwiJrMpoZ1uESbK8FQV3LXkhfry+8bOniq9pqY1OdsjFWNsSbfjPugw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.42.0.tgz", + "integrity": "sha512-g86PF8YZ9GRqkdi0VoGlcDUb4rYtQKyTD1IVtxxN4Hpe7YqLBShA7oHMKU6oKTCi3uxwW4VkIGnOaH/El8de3w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.42.0.tgz", + "integrity": "sha512-+axkdyDGSp6hjyzQ5m1pgcvQScfHnMCcsXkx8pTgy/6qBmWVhtRVlgxjWwDp67wEXXUr0x+vD6tp5W4x6V7u1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.42.0.tgz", + "integrity": "sha512-F+5J9pelstXKwRSDq92J0TEBXn2nfUrQGg+HK1+Tk7VOL09e0gBqUHugZv7SW4MGrYj41oNCUe3IKCDGVlis2g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.42.0.tgz", + "integrity": "sha512-LpHiJRwkaVz/LqjHjK8LCi8osq7elmpwujwbXKNW88bM8eeGxavJIKKjkjpMHAh/2xfnrt1ZSnhTv41WYUHYmA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "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.20.7", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz", + "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/estree": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", + "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/google-protobuf": { + "version": "3.15.12", + "resolved": "https://registry.npmjs.org/@types/google-protobuf/-/google-protobuf-3.15.12.tgz", + "integrity": "sha512-40um9QqwHjRS92qnOaDpL7RmDK15NuZYo9HihiJRbYkMQZlWnuH8AdvbMy8/o6lgLmKbDUKa+OALCltHdbOTpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.0.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.0.tgz", + "integrity": "sha512-yZQa2zm87aRVcqDyH5+4Hv9KYgSdgwX1rFnGvpbzMaC7YAljmhBET93TPiTd3ObwTL+gSpIzPKg5BqVxdCvxKg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "undici-types": "~7.8.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.23", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz", + "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.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/semver": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.0.tgz", + "integrity": "sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", + "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.5.1", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/type-utils": "6.21.0", + "@typescript-eslint/utils": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4", + "graphemer": "^1.4.0", + "ignore": "^5.2.4", + "natural-compare": "^1.4.0", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha", + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", + "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", + "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz", + "integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/utils": "6.21.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", + "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", + "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "9.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", + "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.12", + "@types/semver": "^7.5.0", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "semver": "^7.5.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", + "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "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/@vitejs/plugin-react": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.5.2.tgz", + "integrity": "sha512-QNVT3/Lxx99nMQWJWF7K4N6apUEuT0KlZA3mx/mVaoGj3smm/8rc8ezz15J1pcbcjDK0V15rpHetVfya08r76Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.11", + "@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-beta.0" + } + }, + "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", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.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/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/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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.9.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz", + "integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "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/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "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.25.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.0.tgz", + "integrity": "sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA==", + "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": { + "caniuse-lite": "^1.0.30001718", + "electron-to-chromium": "^1.5.160", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/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/caniuse-lite": { + "version": "1.0.30001721", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001721.tgz", + "integrity": "sha512-cOuvmUVtKrtEaoKiO0rSc29jcjwMwX5tOHDy4MgVFEWiUXj4uBMJkwI8MDySkgXidpMiHUcviogAvFi4pA2hDQ==", + "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/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/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/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "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.166", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.166.tgz", + "integrity": "sha512-QPWqHL0BglzPYyJJ1zSSmwFFL6MFXhbACOCcsCdUMCkzPdS9/OIBVxg516X/Ado2qwAq8k0nJJ7phQPCqiaFAw==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", + "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.5", + "@esbuild/android-arm": "0.25.5", + "@esbuild/android-arm64": "0.25.5", + "@esbuild/android-x64": "0.25.5", + "@esbuild/darwin-arm64": "0.25.5", + "@esbuild/darwin-x64": "0.25.5", + "@esbuild/freebsd-arm64": "0.25.5", + "@esbuild/freebsd-x64": "0.25.5", + "@esbuild/linux-arm": "0.25.5", + "@esbuild/linux-arm64": "0.25.5", + "@esbuild/linux-ia32": "0.25.5", + "@esbuild/linux-loong64": "0.25.5", + "@esbuild/linux-mips64el": "0.25.5", + "@esbuild/linux-ppc64": "0.25.5", + "@esbuild/linux-riscv64": "0.25.5", + "@esbuild/linux-s390x": "0.25.5", + "@esbuild/linux-x64": "0.25.5", + "@esbuild/netbsd-arm64": "0.25.5", + "@esbuild/netbsd-x64": "0.25.5", + "@esbuild/openbsd-arm64": "0.25.5", + "@esbuild/openbsd-x64": "0.25.5", + "@esbuild/sunos-x64": "0.25.5", + "@esbuild/win32-arm64": "0.25.5", + "@esbuild/win32-ia32": "0.25.5", + "@esbuild/win32-x64": "0.25.5" + } + }, + "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": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz", + "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==", + "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" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.20", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.20.tgz", + "integrity": "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.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.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.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/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.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/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.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": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "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.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", + "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", + "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/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "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/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": { + "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/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/glob/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/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/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/google-protobuf": { + "version": "3.21.4", + "resolved": "https://registry.npmjs.org/google-protobuf/-/google-protobuf-3.21.4.tgz", + "integrity": "sha512-MnG7N936zcKTco4Jd2PX2U96Kf9PxygAPKBug+74LHzmHXmceN16MmRcdgZv+DGef/S9YvQAfRsNCn4cjf9yyQ==", + "license": "(BSD-3-Clause AND Apache-2.0)" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "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/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/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.", + "dev": true, + "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==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/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.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "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-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/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/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/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "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/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/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.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "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/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-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "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-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/postcss": { + "version": "8.5.4", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.4.tgz", + "integrity": "sha512-QSa9EBe+uwlGTFmHsPKokv3B/oEMQZxfqW0QqNCyhpa6mB1afzulwn8hihglqAb2pOw+BJgNlmXQ8la2VeHB7w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/proxy-from-env": { + "version": "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/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/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/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.42.0.tgz", + "integrity": "sha512-LW+Vse3BJPyGJGAJt1j8pWDKPd73QM8cRXYK1IxOBgL2AGLu7Xd2YOW0M2sLUBCkF5MshXXtMApyEAEzMVMsnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.7" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.42.0", + "@rollup/rollup-android-arm64": "4.42.0", + "@rollup/rollup-darwin-arm64": "4.42.0", + "@rollup/rollup-darwin-x64": "4.42.0", + "@rollup/rollup-freebsd-arm64": "4.42.0", + "@rollup/rollup-freebsd-x64": "4.42.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.42.0", + "@rollup/rollup-linux-arm-musleabihf": "4.42.0", + "@rollup/rollup-linux-arm64-gnu": "4.42.0", + "@rollup/rollup-linux-arm64-musl": "4.42.0", + "@rollup/rollup-linux-loongarch64-gnu": "4.42.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.42.0", + "@rollup/rollup-linux-riscv64-gnu": "4.42.0", + "@rollup/rollup-linux-riscv64-musl": "4.42.0", + "@rollup/rollup-linux-s390x-gnu": "4.42.0", + "@rollup/rollup-linux-x64-gnu": "4.42.0", + "@rollup/rollup-linux-x64-musl": "4.42.0", + "@rollup/rollup-win32-arm64-msvc": "4.42.0", + "@rollup/rollup-win32-ia32-msvc": "4.42.0", + "@rollup/rollup-win32-x64-msvc": "4.42.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/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-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/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-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/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/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", + "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.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/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", + "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "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/vite": { + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", + "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "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" + } + } + } +} diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..d908410 --- /dev/null +++ b/client/package.json @@ -0,0 +1,35 @@ +{ + "name": "vibestonk-client", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.6.2", + "google-protobuf": "^3.21.4", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@changyenh/protoc-gen-ts": "^0.8.3", + "@types/google-protobuf": "^3.15.12", + "@types/react": "^18.2.37", + "@types/react-dom": "^18.2.15", + "@typescript-eslint/eslint-plugin": "^6.10.0", + "@typescript-eslint/parser": "^6.10.0", + "@vitejs/plugin-react": "^4.2.0", + "autoprefixer": "^10.4.16", + "eslint": "^8.53.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.4", + "postcss": "^8.4.32", + "tailwindcss": "^3.3.6", + "typescript": "^5.2.2", + "vite": "^6.3.5" + } +} diff --git a/client/postcss.config.js b/client/postcss.config.js new file mode 100644 index 0000000..e99ebc2 --- /dev/null +++ b/client/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} \ No newline at end of file diff --git a/client/src/App.tsx b/client/src/App.tsx new file mode 100644 index 0000000..9b32fb1 --- /dev/null +++ b/client/src/App.tsx @@ -0,0 +1,69 @@ +import {useState, useEffect} from 'react' +import AuthenticatedLayout from './layouts/AuthenticatedLayout.tsx' +import UnauthenticatedLayout from "./layouts/UnauthenticatedLayout.tsx"; +import {api} from "./types/generated/user.ts"; +import User = api.v1.User; +import {authService} from "./services/authService.ts"; +import { ThemeProvider } from './context/ThemeContext.tsx'; +import ThemeToggle from './components/ThemeToggle.tsx'; + +/** + * App represents the deepest root of the client. It handles client authentication and then defers to the deeper systems + * @constructor + */ +function App() { + const [isAuthenticated, setIsAuthenticated] = useState(null) + const [currentUser, setCurrentUser] = useState(new User()) + + // Check if user is already authenticated on component mount + useEffect(() => { + + const checkAuth: () => Promise = async (): Promise => { + try { + if (authService.isAuthenticated()) { + // Try to get current user with the stored token + const response = await authService.getCurrentUser(); + setCurrentUser(response.data); + setIsAuthenticated(true); + }else{ + setIsAuthenticated(false); + } + } catch (error) { + console.error('Authentication check failed:', error) + // If the token is invalid, clear it + authService.logout() + setIsAuthenticated(false) + } + } + + checkAuth() + }, []) + + const handleAuthSuccess = (token: string, user: User) => { + user.hash = token + setCurrentUser(user) + setIsAuthenticated(true) + } + + const pickDisplay = (isAuthenticated: boolean | null) =>{ + switch (isAuthenticated){ + case null: + return (
); + case true: + return (); + case false: + return (); + } + } + + return ( + +
+ + {pickDisplay(isAuthenticated)} +
+
+ ) +} + +export default App diff --git a/client/src/components/GreetingForm.tsx b/client/src/components/GreetingForm.tsx new file mode 100644 index 0000000..0a3fe05 --- /dev/null +++ b/client/src/components/GreetingForm.tsx @@ -0,0 +1,54 @@ +import {useState} from 'react'; +import {healthService} from "../services/healthService.ts"; +import {api} from "../types/generated/user.ts"; +import User = api.v1.User; + +export interface GreetingFormProps { + user: User +} + +const GreetingForm: React.FC = ({user}) => { + const [name, setName] = useState(''); + const [label, setLabel] = useState(user.prefName); + const [isLoading, setIsLoading] = useState(false); + + + const handleSubmit = (e: React.FormEvent) => { + setIsLoading(true); + e.preventDefault(); + + if (name.trim()) { + healthService.getGreeting(name) + .then((res) => { + setLabel(res.data); + }) + .catch((error) => { + console.log(error); + }).finally(() => { + setIsLoading(false); + }); + } + }; + + return ( +
+ Greetings, {label} +
+ You can alias for now using the field below. +
+ setName(e.target.value)} + placeholder="Enter your name" + disabled={isLoading} + /> + +
+
+ ); +}; + +export default GreetingForm; diff --git a/client/src/components/LoginForm.tsx b/client/src/components/LoginForm.tsx new file mode 100644 index 0000000..4a76628 --- /dev/null +++ b/client/src/components/LoginForm.tsx @@ -0,0 +1,79 @@ +import React, { useState } from 'react'; +import {api} from "../types/generated/user.ts"; +import UserRegistrationResponse = api.v1.UserRegistrationResponse; +import {authService} from "../services/authService.ts"; + +interface LoginFormProps { + onLoginSuccess: (token: string, user: any) => void; + swap: () => void; +} + +const LoginForm: React.FC = ({ onLoginSuccess}) => { + const [userName, setUserName] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setIsLoading(true); + + try { + const response = await authService.login(userName, password); + const resp = response.data; + const td = new TextEncoder(); + const authResult = UserRegistrationResponse.deserialize(td.encode(resp)); + + console.log(`authResult: ${JSON.stringify(authResult.user)}`); + + // Store the token + authService.setAuthToken(authResult.token); + + // Notify parent component + onLoginSuccess(authResult.token, authResult.user); + } catch (err) { + console.error('Login error:', err); + setError('Invalid username or password'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+

Login

+ {error &&
{error}
} + +
+
+ + setUserName(e.target.value)} + required + /> +
+ +
+ + setPassword(e.target.value)} + required + /> +
+ + +
+
+ ); +}; + +export default LoginForm; \ No newline at end of file diff --git a/client/src/components/NavAuthenticated.tsx b/client/src/components/NavAuthenticated.tsx new file mode 100644 index 0000000..d52a1d7 --- /dev/null +++ b/client/src/components/NavAuthenticated.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import {AuthView} from "../types/viewEnums.ts"; +import {authService} from "../services/authService.ts"; + +interface AuthNavParams { + setView(view: AuthView): void; + + setIsAuthenticated(auth: boolean): void; +} + +const NavAuthenticated: React.FC = ({setView, setIsAuthenticated}) => { + const handleLogout = () => { + authService.logout(); + setIsAuthenticated(false); + } + return ( +
+

VibeStonk

+
+ +
+
+ ) +}; + +export default NavAuthenticated; \ No newline at end of file diff --git a/client/src/components/NavUnauthenticated.tsx b/client/src/components/NavUnauthenticated.tsx new file mode 100644 index 0000000..0ffe5e5 --- /dev/null +++ b/client/src/components/NavUnauthenticated.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import {UnauthView} from "../types/viewEnums.ts"; + +interface AuthNavParams { + setView(view: UnauthView): void +} + +const NavUnauthenticated: React.FC = ({setView}) => { + return ( +
+

VibeStonk

+
+ +
+
+ ) +}; + +export default NavUnauthenticated; \ No newline at end of file diff --git a/client/src/components/RegisterForm.tsx b/client/src/components/RegisterForm.tsx new file mode 100644 index 0000000..0a7d871 --- /dev/null +++ b/client/src/components/RegisterForm.tsx @@ -0,0 +1,108 @@ +import React, {useState} from 'react'; +import {api} from "../types/generated/user.ts"; +import UserRegistrationResponse = api.v1.UserRegistrationResponse; +import {authService} from "../services/authService.ts"; + +interface RegisterFormProps { + onRegisterSuccess: (token: string, user: any) => void; + swap: () => void; +} + +const RegisterForm: React.FC = ({ onRegisterSuccess}: RegisterFormProps) => { + const [userName, setUserName] = useState(''); + const [prefName, setPrefName] = useState(''); + const [password, setPassword] = useState(''); + const [passwordConfirm, setPasswordConfirm] = useState(''); + const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent): Promise => { + e.preventDefault(); + setError(''); + + // Validate passwords match + if (password !== passwordConfirm) { + setError('Passwords do not match'); + return; + } + + setIsLoading(true); + + try { + const response = await authService.register(userName, prefName, password, passwordConfirm); + const resp = response.data; + const te = new TextEncoder(); + const authResponse = UserRegistrationResponse.deserialize(te.encode(resp)); + + // Store the token + authService.setAuthToken(authResponse.token); + + // Notify parent component + onRegisterSuccess(authResponse.token, authResponse.user); + } catch (err) { + console.error('Registration error:', err); + setError('Registration failed. Username may be taken.'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+

Register

+ {error &&
{error}
} + +
+
+ + setUserName(e.target.value)} + required + /> +
+ +
+ + setPrefName(e.target.value)} + required + /> +
+ +
+ + setPassword(e.target.value)} + required + /> +
+ +
+ + setPasswordConfirm(e.target.value)} + required + /> +
+ + +
+
+ ); +}; + +export default RegisterForm; \ No newline at end of file diff --git a/client/src/components/SiteFooter.tsx b/client/src/components/SiteFooter.tsx new file mode 100644 index 0000000..12544e6 --- /dev/null +++ b/client/src/components/SiteFooter.tsx @@ -0,0 +1,11 @@ +import React from "react"; + +const SiteFooter: React.FC = () =>{ + return ( +
+

© {new Date().getFullYear()} Vibestonk

+
+ ) +}; + +export default SiteFooter; \ No newline at end of file diff --git a/client/src/components/ThemeToggle.tsx b/client/src/components/ThemeToggle.tsx new file mode 100644 index 0000000..db68f7d --- /dev/null +++ b/client/src/components/ThemeToggle.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { useTheme } from '../context/ThemeContext'; + +const ThemeToggle: React.FC = () => { + const { theme, toggleTheme } = useTheme(); + + return ( + + ); +}; + +export default ThemeToggle; \ No newline at end of file diff --git a/client/src/composition/Stocks.tsx b/client/src/composition/Stocks.tsx new file mode 100644 index 0000000..f6b3667 --- /dev/null +++ b/client/src/composition/Stocks.tsx @@ -0,0 +1,12 @@ + +export interface StockViewProps{ + +} + +const Stocks: React.FC = ({}: StockViewProps)=>{ + return ( +
Hi, I'm a stock view
+ ); +}; + +export default Stocks; \ No newline at end of file diff --git a/client/src/context/ThemeContext.tsx b/client/src/context/ThemeContext.tsx new file mode 100644 index 0000000..acc97d4 --- /dev/null +++ b/client/src/context/ThemeContext.tsx @@ -0,0 +1,67 @@ +import React, { createContext, useState, useEffect, useContext, ReactNode } from 'react'; + +type Theme = 'light' | 'dark'; + +interface ThemeContextType { + theme: Theme; + toggleTheme: () => void; +} + +const ThemeContext = createContext(undefined); + +interface ThemeProviderProps { + children: ReactNode; +} + +export const ThemeProvider: React.FC = ({ children }) => { + // Check if user has a theme preference in localStorage or use system preference + const getInitialTheme = (): Theme => { + const savedTheme = localStorage.getItem('theme') as Theme | null; + + if (savedTheme && (savedTheme === 'light' || savedTheme === 'dark')) { + return savedTheme; + } + + // Check system preference + if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { + return 'dark'; + } + + return 'light'; // Default to light theme + }; + + const [theme, setTheme] = useState(getInitialTheme); + + // Apply theme to document + useEffect(() => { + const root = window.document.documentElement; + + // Remove both classes and add the current theme + root.classList.remove('light', 'dark'); + root.classList.add(theme); + + // Save theme preference to localStorage + localStorage.setItem('theme', theme); + }, [theme]); + + const toggleTheme = () => { + setTheme(prevTheme => (prevTheme === 'light' ? 'dark' : 'light')); + }; + + return ( + + {children} + + ); +}; + +// Custom hook to use the theme context +export const useTheme = (): ThemeContextType => { + const context = useContext(ThemeContext); + + if (context === undefined) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + + return context; +}; \ No newline at end of file diff --git a/client/src/layouts/AuthenticatedLayout.tsx b/client/src/layouts/AuthenticatedLayout.tsx new file mode 100644 index 0000000..0cd3253 --- /dev/null +++ b/client/src/layouts/AuthenticatedLayout.tsx @@ -0,0 +1,37 @@ +import NavAuthenticated from "../components/NavAuthenticated.tsx"; +import React, {useState} from "react"; +import SiteFooter from "../components/SiteFooter.tsx"; +import Stocks from "../composition/Stocks.tsx"; +import GreetingForm from "../components/GreetingForm.tsx"; +import {AuthView} from "../types/viewEnums.ts"; + +interface AuthenticationProps { + user: any; + setIsAuthenticated: (auth: boolean) => void; +} + +const AuthenticatedLayout: React.FC = ({user, setIsAuthenticated}) => { + const [view, setView] = useState(AuthView.Stocks); + const showView = (view: AuthView) => { + switch (view) { + case AuthView.Stocks: + return () + case AuthView.Greeting: + return () + default: + throw new Error(`didn't understand view: ${view})`); + } + }; + + return ( +
+ +
+ {showView(view)} +
+ +
+ ); +}; + +export default AuthenticatedLayout; diff --git a/client/src/layouts/UnauthenticatedLayout.tsx b/client/src/layouts/UnauthenticatedLayout.tsx new file mode 100644 index 0000000..54d5702 --- /dev/null +++ b/client/src/layouts/UnauthenticatedLayout.tsx @@ -0,0 +1,68 @@ +import React, { useState } from 'react'; +import {UnauthView} from "../types/viewEnums.ts"; +import LoginForm from "../components/LoginForm.tsx"; +import RegisterForm from "../components/RegisterForm.tsx"; +import SiteFooter from "../components/SiteFooter.tsx"; +import NavUnauthenticated from "../components/NavUnauthenticated.tsx"; + +interface AuthPageProps { + onAuthSuccess: (token: string, user: any) => void; +} + +/** + * The UnauthenticatedLanding page is only concerned with serving login and registration forms to an unauthenticated + * user. Upon successful authentication, it will prompt the application to rerender for an authenticated user + * @param onAuthSuccess + * @param setView + * @constructor + */ +const UnauthenticatedLayout: React.FC = ({onAuthSuccess}) => { + const [view, setView] = useState(UnauthView.Login); + + const handleLoginSuccess = (token: string, user: any) => { + onAuthSuccess(token, user); + }; + + const handleRegisterSuccess = (token: string, user: any) => { + onAuthSuccess(token, user); + }; + + const showView = (view: UnauthView) => { + switch (view) { + case UnauthView.Login: + return ( + setView(UnauthView.Register)} + /> + ) + case UnauthView.Register: + return ( + setView(UnauthView.Login)} + /> + ) + default: + throw new Error("main page done goofed"); + } + }; + return ( +
+ +
+

YOU ARE NOT KNOWN

+
+
+
+
+ {showView(view)} +
+
+
+ +
+ ); +}; + +export default UnauthenticatedLayout; \ No newline at end of file diff --git a/client/src/main.tsx b/client/src/main.tsx new file mode 100644 index 0000000..1d95736 --- /dev/null +++ b/client/src/main.tsx @@ -0,0 +1,14 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App.tsx' +// Import Tailwind CSS first (via index.css) +import './styles/index.css' +// Then import custom component styles +import './styles/App.css' + +// this is the entry point for the entire client app. It sets React strict mode and initializes an App.tsx +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/client/src/services/authService.ts b/client/src/services/authService.ts new file mode 100644 index 0000000..4aa7e53 --- /dev/null +++ b/client/src/services/authService.ts @@ -0,0 +1,47 @@ +import {api as userRegistration} from "../types/generated/user-registration.ts"; +import UserRegistration = userRegistration.v1.UserRegistration; +import client from './stonkService.ts'; +import {api} from "../types/generated/login.ts"; +import Login = api.v1.Login; + + + +// User authentication endpoints +export const authService = { + register: (userName: string, prefName: string, password: string, passwordConfirm: string) => { + let te = new TextEncoder(); + let registration = new UserRegistration({ + userName: userName, + prefName: prefName, + pswd: te.encode(password), + pswdConfirm: te.encode(passwordConfirm), + }); + return client.post('/user', registration.serialize()); + }, + + login: (userName: string, password: string) =>{ + let te = new TextEncoder(); + let login = new Login({ + userName: userName, + password: te.encode(password), + }); + return client.post('/user/login', login.serialize()) + }, + + + getCurrentUser: () => client.get('/user'), + + logout: () => { + localStorage.removeItem('authToken'); + localStorage.removeItem('currentUser'); + }, + + setAuthToken: (token: string) => { + localStorage.setItem('authToken', token); + }, + + getAuthToken: () => localStorage.getItem('authToken'), + + isAuthenticated: () => !!localStorage.getItem('authToken') +}; + diff --git a/client/src/services/healthService.ts b/client/src/services/healthService.ts new file mode 100644 index 0000000..f7491f3 --- /dev/null +++ b/client/src/services/healthService.ts @@ -0,0 +1,7 @@ +import client from './stonkService.ts'; +import {AxiosResponse} from "axios"; +// Health endpoints +export const healthService = { + getStatus: (): Promise> => client.get('/health'), + getGreeting: (name: string) => client.post(`/health?name=${encodeURIComponent(name)}`), +}; diff --git a/client/src/services/stonkService.ts b/client/src/services/stonkService.ts new file mode 100644 index 0000000..d8ea480 --- /dev/null +++ b/client/src/services/stonkService.ts @@ -0,0 +1,20 @@ +// Base API configuration +import axios from "axios"; + +const stonkService = axios.create({ + baseURL: '/api/v1', + headers: { + 'Content-Type': 'application/x-protobuf', + }, +}); + +// Add token to requests if available +stonkService.interceptors.request.use(config => { + const token = localStorage.getItem('authToken'); + if (token) { + config.headers['Bearer'] = token; + } + return config; +}); + +export default stonkService; diff --git a/client/src/styles/App.css b/client/src/styles/App.css new file mode 100644 index 0000000..2d518af --- /dev/null +++ b/client/src/styles/App.css @@ -0,0 +1,56 @@ +@tailwind components; + +@layer components { + .app { + @apply w-full text-center bg-background dark:bg-background-dark; + } + + .main-layout { + @apply flex flex-col min-h-screen bg-background dark:bg-background-dark; + } + + .header { + @apply py-6 bg-background dark:bg-background-dark; + } + + .content { + @apply w-full md:w-3/4 mx-auto flex-grow p-4 bg-background dark:bg-background-dark; + } + + .auth-page { + @apply flex justify-center items-center; + } + + .auth-container { + @apply bg-white dark:bg-gray-800 p-8 rounded-lg shadow-md w-full max-w-md; + } + + /* Form elements */ + input, select, textarea { + @apply w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md + bg-white dark:bg-gray-700 text-gray-900 dark:text-white; + } + + button { + @apply px-4 py-2 rounded-md transition-colors; + } + + button.primary { + @apply bg-primary hover:bg-primary-dark text-white; + } + + button.secondary { + @apply bg-secondary hover:bg-secondary-dark text-white; + } + + /* Error message */ + .error-message { + @apply bg-red-500 text-white p-2 rounded-md my-2; + } + + /* Theme toggle */ + .theme-toggle { + @apply fixed top-4 right-4 p-2 rounded-full bg-gray-200 dark:bg-gray-700 + text-gray-800 dark:text-gray-200 cursor-pointer; + } +} diff --git a/client/src/styles/index.css b/client/src/styles/index.css new file mode 100644 index 0000000..857c660 --- /dev/null +++ b/client/src/styles/index.css @@ -0,0 +1,47 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + body { + @apply bg-background dark:bg-background-dark text-text dark:text-text-dark m-0 min-h-screen; + } + + /* Light mode (default) */ + :root { + color-scheme: light; + } + + /* Dark mode */ + .dark { + color-scheme: dark; + } + + h1 { + @apply text-3xl font-bold mb-4; + } + + h2 { + @apply text-2xl font-semibold mb-3; + } + + h3 { + @apply text-xl font-medium mb-2; + } +} + +@layer components { + #root { + @apply max-w-7xl mx-auto p-8; + } +} diff --git a/client/src/types/index.ts b/client/src/types/index.ts new file mode 100644 index 0000000..903170c --- /dev/null +++ b/client/src/types/index.ts @@ -0,0 +1,8 @@ +// Re-export generated types from protobuf + +// Component prop types + +// Legacy types (to be removed once protobuf generation is fully implemented) +export interface GreetingResponse { + message: string; +} diff --git a/client/src/types/viewEnums.ts b/client/src/types/viewEnums.ts new file mode 100644 index 0000000..897f67b --- /dev/null +++ b/client/src/types/viewEnums.ts @@ -0,0 +1,10 @@ + +export enum AuthView { + Stocks = 0, + Greeting = 1, +} + +export enum UnauthView { + Login = 0, + Register = 1, +} diff --git a/client/tailwind.config.js b/client/tailwind.config.js new file mode 100644 index 0000000..2a17abf --- /dev/null +++ b/client/tailwind.config.js @@ -0,0 +1,35 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: { + colors: { + primary: { + light: '#4a90e2', + DEFAULT: '#3b82f6', + dark: '#2563eb', + }, + secondary: { + light: '#f59e0b', + DEFAULT: '#f97316', + dark: '#ea580c', + }, + background: { + light: '#ffffff', + DEFAULT: '#f3f4f6', + dark: '#1f2937', + }, + text: { + light: '#1f2937', + DEFAULT: '#374151', + dark: '#f9fafb', + }, + }, + }, + }, + darkMode: 'class', // This enables dark mode with the 'dark' class + plugins: [], +} \ No newline at end of file diff --git a/client/tsconfig.json b/client/tsconfig.json new file mode 100644 index 0000000..7a7611e --- /dev/null +++ b/client/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} \ No newline at end of file diff --git a/client/tsconfig.node.json b/client/tsconfig.node.json new file mode 100644 index 0000000..099658c --- /dev/null +++ b/client/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} \ No newline at end of file diff --git a/client/vite.config.ts b/client/vite.config.ts new file mode 100644 index 0000000..a733039 --- /dev/null +++ b/client/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + server: { + port: 3000, + proxy: { + '/api': { + target: 'http://localhost:8080/api/v1', + changeOrigin: true, + } + } + } +}) \ No newline at end of file diff --git a/docker/Dockerfile.api b/docker/Dockerfile.api new file mode 100644 index 0000000..3a5d2fc --- /dev/null +++ b/docker/Dockerfile.api @@ -0,0 +1,20 @@ +FROM golang:1.24-alpine AS builder + +WORKDIR /app + +COPY . . + +RUN go mod download + +RUN go build -o /app/server server/cmd/start.go + +FROM alpine:latest + +WORKDIR /app + +COPY --from=builder /app/server/start /bin/server +RUN chmod a+x /bin/server + +EXPOSE 8080 + +CMD ["/bin/server"] diff --git a/docker/Dockerfile.static b/docker/Dockerfile.static new file mode 100644 index 0000000..165220e --- /dev/null +++ b/docker/Dockerfile.static @@ -0,0 +1,7 @@ +FROM nginx:alpine + +COPY dist/content/ /usr/share/nginx/html/ + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8583faa --- /dev/null +++ b/go.mod @@ -0,0 +1,31 @@ +module vibeStonk + +go 1.23.0 + +toolchain go1.23.4 + +require github.com/labstack/echo/v4 v4.13.4 + +require google.golang.org/protobuf v1.36.6 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + golang.org/x/crypto v0.38.0 // indirect + golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect + golang.org/x/net v0.40.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.25.0 // indirect + golang.org/x/time v0.11.0 // indirect + modernc.org/libc v1.65.7 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.37.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6bf5a73 --- /dev/null +++ b/go.sum @@ -0,0 +1,75 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/labstack/echo/v4 v4.11.4 h1:vDZmA+qNeh1pd/cCkEicDMrjtrnMGQ1QFI9gWN1zGq8= +github.com/labstack/echo/v4 v4.11.4/go.mod h1:noh7EvLwqDsmh/X/HWKPUl1AjzJrhyptRyEbQJfxen8= +github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= +github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= +golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= +golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= +golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= +golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= +golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00= +modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs= +modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g= diff --git a/kubes/create-registry-secret.sh b/kubes/create-registry-secret.sh new file mode 100755 index 0000000..d8b00e5 --- /dev/null +++ b/kubes/create-registry-secret.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +read -p "Enter the hostname: " hostname +read -p "Enter the username: " username +read -sp "Enter the secret: " secret + +kubectl create secret -n vibe-stonk docker-registry regsecret --docker-server=${hostname} --docker-username=${username} --docker-password=${secret} \ No newline at end of file diff --git a/kubes/deployment.yaml b/kubes/deployment.yaml new file mode 100644 index 0000000..302fd87 --- /dev/null +++ b/kubes/deployment.yaml @@ -0,0 +1,117 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: ingress + namespace: vibe-stonk + annotations: + nginx.ingress.kubernetes.io/proxy-body-size: "0" + nginx.ingress.kubernetes.io/proxy-read-timeout: "600" + nginx.ingress.kubernetes.io/proxy-send-timeout: "600" +# nginx.ingress.kubernetes.io/rewrite-target: / +spec: + ingressClassName: nginx + rules: + - host: stock.lab.gg + http: + paths: + - pathType: Prefix + path: / + backend: + service: + name: service + port: + number: 60003 + - pathType: Prefix + path: /api + backend: + service: + name: api-service + port: + number: 60004 +--- +apiVersion: v1 +kind: Service +metadata: + name: service + namespace: vibe-stonk +spec: + type: ClusterIP + selector: + app: static + ports: + - protocol: TCP + port: 60003 + targetPort: 80 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + namespace: vibe-stonk + name: static + labels: + app: app +spec: + replicas: 4 + selector: + matchLabels: + app: static + template: + metadata: + labels: + app: static + spec: + tolerations: + - key: "worker" + effect: "NoSchedule" + imagePullSecrets: + - name: regsecret + containers: + - name: static + image: registry.geniuscartel.xyz/stonk/static:latest + imagePullPolicy: Always + ports: + - containerPort: 8080 +--- +apiVersion: v1 +kind: Service +metadata: + name: api-service + namespace: vibe-stonk +spec: + type: ClusterIP + selector: + app: api + ports: + - protocol: TCP + port: 60004 + targetPort: 80 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + namespace: vibe-stonk + name: api + labels: + app: app +spec: + replicas: 1 + selector: + matchLabels: + app: api + template: + metadata: + labels: + app: api + spec: + tolerations: + - key: "worker" + effect: "NoSchedule" + imagePullSecrets: + - name: regsecret + containers: + - name: api-container + image: registry.geniuscartel.xyz/stonk/api:latest + imagePullPolicy: Always + ports: + - containerPort: 8080 +--- diff --git a/kubes/namespace.yaml b/kubes/namespace.yaml new file mode 100644 index 0000000..4f8f990 --- /dev/null +++ b/kubes/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: vibe-stonk \ No newline at end of file diff --git a/proto/v1/holding.proto b/proto/v1/holding.proto new file mode 100644 index 0000000..7a1848e --- /dev/null +++ b/proto/v1/holding.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; + +package api.v1; + +option go_package = "models/v1"; + +import "google/protobuf/timestamp.proto"; + +message Holding{ + int64 id = 1; + google.protobuf.Timestamp purchaseDate = 2; + int64 stockID = 3; + double qty = 4; + double price = 5; + double sold = 6; +} \ No newline at end of file diff --git a/proto/v1/login.proto b/proto/v1/login.proto new file mode 100644 index 0000000..84d0e79 --- /dev/null +++ b/proto/v1/login.proto @@ -0,0 +1,10 @@ +syntax = "proto3"; + +package api.v1; + +option go_package = "models/v1"; + +message Login{ + string userName = 1; + bytes password = 2; +} diff --git a/proto/v1/purchase.proto b/proto/v1/purchase.proto new file mode 100644 index 0000000..b34bdd0 --- /dev/null +++ b/proto/v1/purchase.proto @@ -0,0 +1,15 @@ +syntax = "proto3"; + +package api.v1; + +option go_package = "models/v1"; + +import "google/protobuf/timestamp.proto"; + +message Purchase{ + int64 id = 1; + google.protobuf.Timestamp purchaseDate = 2; + int64 stockID = 3; + double qty = 4; + double price = 5; +} \ No newline at end of file diff --git a/proto/v1/sale-fragment.proto b/proto/v1/sale-fragment.proto new file mode 100644 index 0000000..6d0e23d --- /dev/null +++ b/proto/v1/sale-fragment.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; + +package api.v1; + +option go_package = "models/v1"; + +import "google/protobuf/timestamp.proto"; + +message SaleFragment{ + int64 id = 1; + double qty = 2; + double purchasePrice = 3; + double salePrice = 4; + google.protobuf.Timestamp purchaseDate = 5; + google.protobuf.Timestamp saleDate = 6; +} diff --git a/proto/v1/sale.proto b/proto/v1/sale.proto new file mode 100644 index 0000000..134c3b6 --- /dev/null +++ b/proto/v1/sale.proto @@ -0,0 +1,15 @@ +syntax = "proto3"; + +package api.v1; + +option go_package = "models/v1"; + +import "google/protobuf/timestamp.proto"; + +message Sale{ + int64 id = 1; + google.protobuf.Timestamp saleDate = 2; + int64 stockID = 3; + double qty = 4; + double price = 5; +} diff --git a/proto/v1/server-health.proto b/proto/v1/server-health.proto new file mode 100644 index 0000000..764db83 --- /dev/null +++ b/proto/v1/server-health.proto @@ -0,0 +1,9 @@ +syntax = "proto3"; + +package api.v1; + +option go_package = "models/v1"; + +message ServerStatus{ + string status = 1; +} \ No newline at end of file diff --git a/proto/v1/session.proto b/proto/v1/session.proto new file mode 100644 index 0000000..f078cc1 --- /dev/null +++ b/proto/v1/session.proto @@ -0,0 +1,15 @@ +syntax = "proto3"; + +package api.v1; +import "google/protobuf/timestamp.proto"; + +option go_package = "models/v1"; + +message Session{ + string id = 1; + string userID = 2; + bool revoked = 3; + string token = 4; + google.protobuf.Timestamp created = 5; + google.protobuf.Timestamp expires = 6; +} diff --git a/proto/v1/stock.proto b/proto/v1/stock.proto new file mode 100644 index 0000000..479f714 --- /dev/null +++ b/proto/v1/stock.proto @@ -0,0 +1,12 @@ +syntax = "proto3"; + +package api.v1; + +option go_package = "models/v1"; + +message Stock { + int64 id = 1; + string symbol = 2; + string name = 3; + string color = 4; +} \ No newline at end of file diff --git a/proto/v1/transaction.proto b/proto/v1/transaction.proto new file mode 100644 index 0000000..2b0470e --- /dev/null +++ b/proto/v1/transaction.proto @@ -0,0 +1,12 @@ +syntax = "proto3"; + +package api.v1; + +option go_package = "models/v1"; + +message Transaction{ + int64 id = 1; + int64 purchaseID = 2; + int64 saleID = 3; + double qty = 4; +} diff --git a/proto/v1/user-registration.proto b/proto/v1/user-registration.proto new file mode 100644 index 0000000..ca038b5 --- /dev/null +++ b/proto/v1/user-registration.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +package api.v1; + + +option go_package = "models/v1"; + +message UserRegistration{ + string userName = 1; + string prefName = 2; + bytes pswd = 3; + bytes pswdConfirm = 4; +} + diff --git a/proto/v1/user.proto b/proto/v1/user.proto new file mode 100644 index 0000000..e55c964 --- /dev/null +++ b/proto/v1/user.proto @@ -0,0 +1,18 @@ +syntax = "proto3"; + +package api.v1; + + +option go_package = "models/v1"; + +message User{ + string id = 1; + string userName = 2; + string prefName = 3; + string hash = 4; +} + +message UserRegistrationResponse{ + User user = 1; + string token = 2; +} diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..a5aaf5a --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,22 @@ +#!/bin/bash +set -xe + +cd $GOPATH/src/vibeStonk || exit + +P_ROOT=$(pwd) + +rm -rf dist/content dist/server + +mkdir -p dist/content + +./scripts/generate_proto.sh + +go build -o dist/server server/cmd/start.go + +cd client +npm run build +cd "${P_ROOT}" || exit + +rsync -avP client/dist/* dist/content/ + +rm -rf client/dist \ No newline at end of file diff --git a/scripts/build_docker.sh b/scripts/build_docker.sh new file mode 100755 index 0000000..7bdce90 --- /dev/null +++ b/scripts/build_docker.sh @@ -0,0 +1,46 @@ +#!/bin/bash +set -xe + +cd $GOPATH/src/vibeStonk || exit + +# Build the project first to generate the static assets and server binary +./scripts/generate_proto.sh +./scripts/build.sh + +# Set the image ID (you can replace this with a version or commit hash) +IMAGE_ID=$(git rev-parse --short HEAD 2>/dev/null || echo "latest") + +# Registry URL +REGISTRY="registry.geniuscartel.xyz/stonk" + +# Build and push the static image +echo "Building and pushing static image..." +docker buildx create --name multiarch-builder --use || true +docker buildx inspect --bootstrap + +# Build and push the static image for multiple architectures +#docker buildx build --platform linux/amd64,linux/arm64 \ +# -t ${REGISTRY}/static:${IMAGE_ID} \ +# -t ${REGISTRY}/static:latest \ +# -f docker/Dockerfile.static \ +# --push \ +# . +docker buildx build --platform linux/arm64 \ + -t ${REGISTRY}/static:${IMAGE_ID} \ + -t ${REGISTRY}/static:latest \ + -f docker/Dockerfile.static \ + --push \ + . + +# Build and push the API image for multiple architectures +echo "Building and pushing API image..." +docker buildx build --platform linux/amd64,linux/arm64 \ + -t ${REGISTRY}/api:${IMAGE_ID} \ + -t ${REGISTRY}/api:latest \ + -f docker/Dockerfile.api \ + --push \ + . + +echo "Docker images built and pushed successfully with tags: ${IMAGE_ID} and latest" +echo "Static images: ${REGISTRY}/static:${IMAGE_ID} and ${REGISTRY}/static:latest" +echo "API images: ${REGISTRY}/api:${IMAGE_ID} and ${REGISTRY}/api:latest" diff --git a/scripts/build_docker_local.sh b/scripts/build_docker_local.sh new file mode 100755 index 0000000..f08ed6d --- /dev/null +++ b/scripts/build_docker_local.sh @@ -0,0 +1,36 @@ +#!/bin/bash +set -xe + +cd $GOPATH/src/vibeStonk || exit + +# Build the project first to generate the static assets and server binary +./scripts/generate_proto.sh +./scripts/build.sh + +# Set the image ID (you can replace this with a version or commit hash) +IMAGE_ID="local" + +# Registry URL +REGISTRY="registry.geniuscartel.xyz/stonk" + +# Build the static image locally +echo "Building static image locally..." +docker build \ + -t ${REGISTRY}/static:${IMAGE_ID} \ + -f docker/Dockerfile.static \ + . + +# Build the API image locally +echo "Building API image locally..." +docker build \ + -t ${REGISTRY}/api:${IMAGE_ID} \ + -f docker/Dockerfile.api \ + . + +echo "Docker images built locally with tag: ${IMAGE_ID}" +echo "Static image: ${REGISTRY}/static:${IMAGE_ID}" +echo "API image: ${REGISTRY}/api:${IMAGE_ID}" +echo "" +echo "To run the containers locally:" +echo "docker run -p 8080:80 ${REGISTRY}/static:${IMAGE_ID}" +echo "docker run -p 8081:8080 ${REGISTRY}/api:${IMAGE_ID}" \ No newline at end of file diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100644 index 0000000..57babdb --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +cd $GOPATH/src/vibeStonk/ || exit + +./scripts/build_docker.sh || exit + +kubectl rollout restart deployment -n vibe-stonk \ No newline at end of file diff --git a/scripts/generate_proto.sh b/scripts/generate_proto.sh new file mode 100755 index 0000000..0d32be4 --- /dev/null +++ b/scripts/generate_proto.sh @@ -0,0 +1,35 @@ +#!/bin/bash +set -xe + +cd $GOPATH/src/vibeStonk || exit + +P_ROOT=$(pwd) + +# This script generates Go code and TypeScript definitions from protobuf definitions + +# Remove old files +rm -rf server/models/ +rm -rf client/src/types/generated/ + +# Create directory for TypeScript definitions +mkdir -p client/src/types/generated + +# Generate Go code from protobuf definitions + +time $(protoc --go_out=${P_ROOT}/server proto/v1/*.proto) + +time $(protoc \ + -I proto/v1 \ + --ts_out=${P_ROOT}/client/src/types/generated \ + user.proto user-registration.proto \ + login.proto \ + stock.proto \ + holding.proto purchase.proto \ + sale.proto sale-fragment.proto \ + transaction.proto \ + server-health.proto \ + +) + + +echo "Proto generation completed successfully!" diff --git a/scripts/remove_test_dbs.sh b/scripts/remove_test_dbs.sh new file mode 100644 index 0000000..3ef2a84 --- /dev/null +++ b/scripts/remove_test_dbs.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +set -xe + +cd $GOPATH/src/vibeStonk || exit + +rm -rf dist/test-data +rm -rf server/services/test-data \ No newline at end of file diff --git a/server/cmd/scratch.go b/server/cmd/scratch.go new file mode 100644 index 0000000..da29a2c --- /dev/null +++ b/server/cmd/scratch.go @@ -0,0 +1,4 @@ +package main + +func main() { +} diff --git a/server/cmd/start.go b/server/cmd/start.go new file mode 100644 index 0000000..3bc97ae --- /dev/null +++ b/server/cmd/start.go @@ -0,0 +1,40 @@ +package main + +import ( + "log" + "vibeStonk/server/repository" + "vibeStonk/server/routes" +) + +func main() { + // Create a new Echo instance + + config := repository.GetDefaultConfig() + server, err := routes.NewAPIServer(config) + if err != nil { + log.Fatalf("failed to start api server: %+v", err) + } + + err = setupRoutes(server) + if err != nil { + log.Fatalf("failed to initialize api routes: %+v", err) + } + + // Start server + if err := server.Start(); err != nil { + log.Fatalf("Error starting server: %v", err) + } +} + +func setupRoutes(server routes.ApiServer) error { + healthRoute := routes.NewHealthRoute() + + userRoutes := routes.NewUserRoute(server.GetSystemServices()) + + apiRoutes := []routes.Provider{ + healthRoute, + userRoutes, + } + + return server.AddRoutesBulk(apiRoutes) +} diff --git a/server/repository/config.go b/server/repository/config.go new file mode 100644 index 0000000..ffea7a6 --- /dev/null +++ b/server/repository/config.go @@ -0,0 +1,59 @@ +package repository + +import "errors" + +var ( + ErrNilConfig = errors.New("config was nil") +) + +func GetSqliteTestConfig() *Config { + config := GetDefaultConfig() + config.DataRoot = "test-data/" + config.SystemDBName = "test" + config.StockDBName = "test_stock" + return config +} + +func GetTestConfigs() []*Config { + engines := []DatabaseEngine{ + Sqlite, + } + + configs := make([]*Config, 0, len(engines)) + + for _, engine := range engines { + config := GetDefaultConfig() + config.DBEngine = engine + config.DataRoot = "test-data/" + configs = append(configs, config) + } + + return configs +} + +func GetDefaultConfig() *Config { + return &Config{ + DBEngine: Sqlite, + DataRoot: "data/", + SystemDBName: "system", + StockDBName: "stocks", + ListenAddress: "0.0.0.0:8080", + } +} + +type Config struct { + // database + DBEngine DatabaseEngine + DataRoot string + SystemDBName string + StockDBName string + + // server + ListenAddress string +} + +type DatabaseEngine int + +const ( + Sqlite DatabaseEngine = iota +) diff --git a/server/repository/defDB.go b/server/repository/defDB.go new file mode 100644 index 0000000..770c348 --- /dev/null +++ b/server/repository/defDB.go @@ -0,0 +1,39 @@ +package repository + +import ( + "database/sql" + "errors" + "fmt" + models "vibeStonk/server/models/v1" +) + +var ( + ErrNoDBName = errors.New("no name for system DB") +) + +func GetSystemConnector(config *Config) (*sql.DB, error) { + switch config.DBEngine { + case Sqlite: + return getSqliteConnection(config.DataRoot, config.SystemDBName) + default: + return nil, fmt.Errorf("unsupported database engine: %v", config.DBEngine) + } +} + +func GetStockConnector(config *Config) (*sql.DB, error) { + switch config.DBEngine { + case Sqlite: + return getSqliteConnection(config.DataRoot, config.StockDBName) + default: + return nil, fmt.Errorf("unsupported database engine: %v", config.DBEngine) + } +} + +func GetUserConnector(config *Config, user *models.User) (*sql.DB, error) { + switch config.DBEngine { + case Sqlite: + return getSqliteConnection(config.DataRoot, user.Id) + default: + return nil, fmt.Errorf("unsupported database engine: %v", config.DBEngine) + } +} diff --git a/server/repository/defHolding.go b/server/repository/defHolding.go new file mode 100644 index 0000000..de1a127 --- /dev/null +++ b/server/repository/defHolding.go @@ -0,0 +1,43 @@ +package repository + +import ( + "database/sql" + "fmt" + models "vibeStonk/server/models/v1" +) + +type HoldingRepo interface { + // GetAll returns all holdings (purchases with sold quantity information) + GetAll() ([]*models.Holding, error) + + // GetUnsold returns holdings that are not fully sold (including partially sold) + GetUnsold() ([]*models.Holding, error) + + // GetSold returns holdings that are fully sold + GetSold() ([]*models.Holding, error) + + // GetByID returns a specific holding by purchaseID + GetByID(purchaseID int64) (*models.Holding, error) + + // GetByStockID returns holdings for a specific stock + GetByStockID(stockID int64) ([]*models.Holding, error) + + // GetUnsoldByStockID returns unsold holdings for a specific stock ID + GetUnsoldByStockID(stockID int64) ([]*models.Holding, error) + + // GetSoldByStockID returns sold holdings for a specific stock ID + GetSoldByStockID(stockID int64) ([]*models.Holding, error) +} + +func NewHoldingRepo(config *Config, db *sql.DB) (HoldingRepo, error) { + switch config.DBEngine { + case Sqlite: + repo, err := newSqliteHoldingRepo(db) + if err != nil { + return nil, fmt.Errorf(errMsgFailed, "Sqlite", err) + } + return repo, nil + default: + return nil, ErrBadEngine + } +} diff --git a/server/repository/defPurchase.go b/server/repository/defPurchase.go new file mode 100644 index 0000000..2952967 --- /dev/null +++ b/server/repository/defPurchase.go @@ -0,0 +1,29 @@ +package repository + +import ( + "database/sql" + "fmt" + models "vibeStonk/server/models/v1" +) + +type PurchaseRepo interface { + Create(purchase *models.Purchase) (*models.Purchase, error) + Get(id int64) (*models.Purchase, error) + GetByStockID(stockID int64) ([]*models.Purchase, error) + Update(purchase *models.Purchase) error + Delete(purchase *models.Purchase) error + List() ([]*models.Purchase, error) +} + +func NewPurchaseRepo(config *Config, db *sql.DB) (PurchaseRepo, error) { + switch config.DBEngine { + case Sqlite: + repo, err := newSqlitePurchaseRepo(db) + if err != nil { + return nil, fmt.Errorf(errMsgFailed, "Sqlite", err) + } + return repo, nil + default: + return nil, ErrBadEngine + } +} diff --git a/server/repository/defSale.go b/server/repository/defSale.go new file mode 100644 index 0000000..b27b2df --- /dev/null +++ b/server/repository/defSale.go @@ -0,0 +1,28 @@ +package repository + +import ( + "database/sql" + "fmt" + "time" + models "vibeStonk/server/models/v1" +) + +type SaleRepo interface { + BeginSale(stockID int, saleDate time.Time, qty, price float64) (*models.Sale, *sql.Tx, error) + Get(id int64) (*models.Sale, error) + GetByStockID(stockID int64) ([]*models.Sale, error) + BeginDelete(sale *models.Sale) (*sql.Tx, error) +} + +func NewSaleRepo(config *Config, db *sql.DB) (SaleRepo, error) { + switch config.DBEngine { + case Sqlite: + repo, err := newSqliteSaleRepo(db) + if err != nil { + return nil, fmt.Errorf(errMsgFailed, "Sqlite", err) + } + return repo, nil + default: + return nil, ErrBadEngine + } +} diff --git a/server/repository/defSaleFragment.go b/server/repository/defSaleFragment.go new file mode 100644 index 0000000..343d2c8 --- /dev/null +++ b/server/repository/defSaleFragment.go @@ -0,0 +1,20 @@ +package repository + +import ( + "database/sql" + models "vibeStonk/server/models/v1" +) + +func NewSaleFragmentRepo(config *Config, db *sql.DB) (SaleFragmentRepo, error) { + switch config.DBEngine { + case Sqlite: + return newSqliteSaleFragmentRepo(db), nil + default: + return nil, ErrBadEngine + } +} + +type SaleFragmentRepo interface { + GetBySaleID(saleID int64) ([]*models.SaleFragment, error) + GetByPurchaseID(purchaseID int64) ([]*models.SaleFragment, error) +} diff --git a/server/repository/defSession.go b/server/repository/defSession.go new file mode 100644 index 0000000..2a6e3aa --- /dev/null +++ b/server/repository/defSession.go @@ -0,0 +1,22 @@ +package repository + +import ( + "database/sql" + models "vibeStonk/server/models/v1" +) + +type SessionRepo interface { + Create(user *models.User) (*models.Session, error) + Get(token string) (*models.Session, error) + Revoke(session *models.Session) error + DeleteExpired() error +} + +func NewSessionRepo(config *Config, db *sql.DB) (SessionRepo, error) { + switch config.DBEngine { + case Sqlite: + return newSqliteSessionRepo(db), nil + default: + return nil, ErrBadEngine + } +} diff --git a/server/repository/defStock.go b/server/repository/defStock.go new file mode 100644 index 0000000..17d8aac --- /dev/null +++ b/server/repository/defStock.go @@ -0,0 +1,30 @@ +package repository + +import ( + "database/sql" + "fmt" + models "vibeStonk/server/models/v1" +) + +type StockRepo interface { + Create(stock *models.Stock) (*models.Stock, error) + Get(id int64) (*models.Stock, error) + GetBySymbol(symbol string) (*models.Stock, error) + Update(stock *models.Stock) error + Delete(stock *models.Stock) error + List() ([]*models.Stock, error) + GetByIDs(ids []int) ([]*models.Stock, error) +} + +func NewStockRepo(config *Config, db *sql.DB) (StockRepo, error) { + switch config.DBEngine { + case Sqlite: + repo, err := newSqliteStockRepo(db) + if err != nil { + return nil, fmt.Errorf(errMsgFailed, "Sqlite", err) + } + return repo, nil + default: + return nil, ErrBadEngine + } +} diff --git a/server/repository/defTransaction.go b/server/repository/defTransaction.go new file mode 100644 index 0000000..1bdc08c --- /dev/null +++ b/server/repository/defTransaction.go @@ -0,0 +1,26 @@ +package repository + +import ( + "database/sql" + "fmt" + models "vibeStonk/server/models/v1" +) + +type TransactionRepo interface { + Create(transaction *models.Transaction) (*models.Transaction, error) + DeleteBySaleID(saleID int64) error + List() ([]*models.Transaction, error) +} + +func NewTransactionRepo(config *Config, db *sql.DB) (TransactionRepo, error) { + switch config.DBEngine { + case Sqlite: + repo, err := newSqliteTransactionRepo(db) + if err != nil { + return nil, fmt.Errorf(errMsgFailed, "Sqlite", err) + } + return repo, nil + default: + return nil, ErrBadEngine + } +} diff --git a/server/repository/defUsers.go b/server/repository/defUsers.go new file mode 100644 index 0000000..accf3cf --- /dev/null +++ b/server/repository/defUsers.go @@ -0,0 +1,37 @@ +package repository + +import ( + "database/sql" + "errors" + "fmt" + models "vibeStonk/server/models/v1" +) + +const ( + errMsgFailed = "failed to initialize repo with engine[%s]: %w" +) + +var ( + ErrBadEngine = errors.New("bad config.DBEngine") +) + +type UserRepo interface { + Create(user *models.User) (*models.User, error) + Get(id string) (*models.User, error) + GetByUsername(username string) (*models.User, error) + Update(user *models.User) error + Delete(user *models.User) error +} + +func NewUserRepo(config *Config, db *sql.DB) (UserRepo, error) { + switch config.DBEngine { + case Sqlite: + repo, err := newSqliteUsersRepo(db) + if err != nil { + return nil, fmt.Errorf(errMsgFailed, "Sqlite", err) + } + return repo, nil + default: + return nil, ErrBadEngine + } +} diff --git a/server/repository/sqliteDB.go b/server/repository/sqliteDB.go new file mode 100644 index 0000000..864dfa0 --- /dev/null +++ b/server/repository/sqliteDB.go @@ -0,0 +1,46 @@ +package repository + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + + _ "modernc.org/sqlite" // Import the SQLite driver +) + +func getSqliteFilename(dir, name string) (string, error) { + if len(name) == 0 { + return "", ErrNoDBName + } + + return filepath.Join(dir, name+".sqlite"), nil +} + +func getSqliteConnection(dir, name string) (*sql.DB, error) { + // make sure the data directory exists + dbPath, err := getSqliteFilename(dir, name) + if err != nil { + return nil, err + } + + err = os.MkdirAll(filepath.Dir(dbPath), 0755) + if err != nil { + return nil, fmt.Errorf("failed to ensure data directory for sqlite database: %w", err) + } + + // Initialize and return the connection to the sqlite database + // Using the modernc.org/sqlite driver with the "sqlite" driver name + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, fmt.Errorf("failed to open sqlite database: %w", err) + } + + // Test the connection + if err := db.Ping(); err != nil { + db.Close() // Close the connection if ping fails + return nil, fmt.Errorf("failed to ping sqlite database: %w", err) + } + + return db, nil +} diff --git a/server/repository/sqliteHolding.go b/server/repository/sqliteHolding.go new file mode 100644 index 0000000..d9f6b6c --- /dev/null +++ b/server/repository/sqliteHolding.go @@ -0,0 +1,333 @@ +package repository + +import ( + "database/sql" + "errors" + "fmt" + "time" + models "vibeStonk/server/models/v1" + + "google.golang.org/protobuf/types/known/timestamppb" +) + +var ( + ErrHoldingNotFound = errors.New("holding not found") +) + +func newSqliteHoldingRepo(db *sql.DB) (HoldingRepo, error) { + repo := &sqliteHoldingRepo{db: db} + return repo, nil +} + +type sqliteHoldingRepo struct { + db *sql.DB +} + +// Helper function to convert a purchase row to a holding +func (s *sqliteHoldingRepo) purchaseToHolding( + id int64, + purchaseDate time.Time, + stockID int64, + qty float64, + price float64, + sold sql.NullFloat64, +) *models.Holding { + holding := &models.Holding{ + Id: id, + PurchaseDate: timestamppb.New(purchaseDate), + StockID: stockID, + Qty: qty, + Price: price, + Sold: 0, // Default to 0 if no transactions + } + + // If sold is not NULL, use its value + if sold.Valid { + holding.Sold = sold.Float64 + } + + return holding +} + +// GetAll returns all holdings (purchases with sold quantity information) +func (s *sqliteHoldingRepo) GetAll() ([]*models.Holding, error) { + query := ` + SELECT p.id, p.purchase_date, p.stock_id, p.qty, p.price, + COALESCE(SUM(t.qty), 0) as sold_qty + FROM purchases p + LEFT JOIN transactions t ON p.id = t.purchase_id + GROUP BY p.id + ORDER BY p.purchase_date DESC + ` + rows, err := s.db.Query(query) + if err != nil { + return nil, fmt.Errorf("failed to get all holdings: %w", err) + } + defer rows.Close() + + var holdings []*models.Holding + for rows.Next() { + var id int64 + var purchaseDate time.Time + var stockID int64 + var qty float64 + var price float64 + var sold sql.NullFloat64 + + err := rows.Scan(&id, &purchaseDate, &stockID, &qty, &price, &sold) + if err != nil { + return nil, fmt.Errorf("failed to scan holding row: %w", err) + } + + holding := s.purchaseToHolding(id, purchaseDate, stockID, qty, price, sold) + holdings = append(holdings, holding) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating holding rows: %w", err) + } + + return holdings, nil +} + +// GetUnsold returns holdings that are not fully sold (including partially sold) +func (s *sqliteHoldingRepo) GetUnsold() ([]*models.Holding, error) { + query := ` + SELECT p.id, p.purchase_date, p.stock_id, p.qty, p.price, + COALESCE(SUM(t.qty), 0) as sold_qty + FROM purchases p + LEFT JOIN transactions t ON p.id = t.purchase_id + GROUP BY p.id + HAVING p.qty > COALESCE(SUM(t.qty), 0) + ORDER BY p.purchase_date DESC + ` + rows, err := s.db.Query(query) + if err != nil { + return nil, fmt.Errorf("failed to get unsold holdings: %w", err) + } + defer rows.Close() + + var holdings []*models.Holding + for rows.Next() { + var id int64 + var purchaseDate time.Time + var stockID int64 + var qty float64 + var price float64 + var sold sql.NullFloat64 + + err := rows.Scan(&id, &purchaseDate, &stockID, &qty, &price, &sold) + if err != nil { + return nil, fmt.Errorf("failed to scan holding row: %w", err) + } + + holding := s.purchaseToHolding(id, purchaseDate, stockID, qty, price, sold) + holdings = append(holdings, holding) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating holding rows: %w", err) + } + + return holdings, nil +} + +// GetSold returns holdings that are fully sold +func (s *sqliteHoldingRepo) GetSold() ([]*models.Holding, error) { + query := ` + SELECT p.id, p.purchase_date, p.stock_id, p.qty, p.price, + COALESCE(SUM(t.qty), 0) as sold_qty + FROM purchases p + LEFT JOIN transactions t ON p.id = t.purchase_id + GROUP BY p.id + HAVING p.qty <= COALESCE(SUM(t.qty), 0) + ORDER BY p.purchase_date DESC + ` + rows, err := s.db.Query(query) + if err != nil { + return nil, fmt.Errorf("failed to get sold holdings: %w", err) + } + defer rows.Close() + + var holdings []*models.Holding + for rows.Next() { + var id int64 + var purchaseDate time.Time + var stockID int64 + var qty float64 + var price float64 + var sold sql.NullFloat64 + + err := rows.Scan(&id, &purchaseDate, &stockID, &qty, &price, &sold) + if err != nil { + return nil, fmt.Errorf("failed to scan holding row: %w", err) + } + + holding := s.purchaseToHolding(id, purchaseDate, stockID, qty, price, sold) + holdings = append(holdings, holding) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating holding rows: %w", err) + } + + return holdings, nil +} + +// GetByID returns a specific holding by ID +func (s *sqliteHoldingRepo) GetByID(id int64) (*models.Holding, error) { + query := ` + SELECT p.id, p.purchase_date, p.stock_id, p.qty, p.price, + COALESCE(SUM(t.qty), 0) as sold_qty + FROM purchases p + LEFT JOIN transactions t ON p.id = t.purchase_id + WHERE p.id = ? + GROUP BY p.id + ` + row := s.db.QueryRow(query, id) + + var purchaseDate time.Time + var stockID int64 + var qty float64 + var price float64 + var sold sql.NullFloat64 + + err := row.Scan(&id, &purchaseDate, &stockID, &qty, &price, &sold) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrHoldingNotFound + } + return nil, fmt.Errorf("failed to get holding: %w", err) + } + + holding := s.purchaseToHolding(id, purchaseDate, stockID, qty, price, sold) + return holding, nil +} + +// GetByStockID returns holdings for a specific stock +func (s *sqliteHoldingRepo) GetByStockID(stockID int64) ([]*models.Holding, error) { + query := ` + SELECT p.id, p.purchase_date, p.stock_id, p.qty, p.price, + COALESCE(SUM(t.qty), 0) as sold_qty + FROM purchases p + LEFT JOIN transactions t ON p.id = t.purchase_id + WHERE p.stock_id = ? + GROUP BY p.id + ORDER BY p.purchase_date DESC + ` + rows, err := s.db.Query(query, stockID) + if err != nil { + return nil, fmt.Errorf("failed to get holdings by stock ID: %w", err) + } + defer rows.Close() + + var holdings []*models.Holding + for rows.Next() { + var id int64 + var purchaseDate time.Time + var pStockID int64 + var qty float64 + var price float64 + var sold sql.NullFloat64 + + err := rows.Scan(&id, &purchaseDate, &pStockID, &qty, &price, &sold) + if err != nil { + return nil, fmt.Errorf("failed to scan holding row: %w", err) + } + + holding := s.purchaseToHolding(id, purchaseDate, pStockID, qty, price, sold) + holdings = append(holdings, holding) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating holding rows: %w", err) + } + + return holdings, nil +} + +// GetUnsoldByStockID returns unsold holdings for a specific stock ID +func (s *sqliteHoldingRepo) GetUnsoldByStockID(stockID int64) ([]*models.Holding, error) { + query := ` + SELECT p.id, p.purchase_date, p.stock_id, p.qty, p.price, + COALESCE(SUM(t.qty), 0) as sold_qty + FROM purchases p + LEFT JOIN transactions t ON p.id = t.purchase_id + WHERE p.stock_id = ? + GROUP BY p.id + HAVING p.qty > COALESCE(SUM(t.qty), 0) + ORDER BY p.purchase_date ASC + ` + rows, err := s.db.Query(query, stockID) + if err != nil { + return nil, fmt.Errorf("failed to get unsold holdings by stock ID: %w", err) + } + defer rows.Close() + + var holdings []*models.Holding + for rows.Next() { + var id int64 + var purchaseDate time.Time + var stockID int64 + var qty float64 + var price float64 + var sold sql.NullFloat64 + + err := rows.Scan(&id, &purchaseDate, &stockID, &qty, &price, &sold) + if err != nil { + return nil, fmt.Errorf("failed to scan holding row: %w", err) + } + + holding := s.purchaseToHolding(id, purchaseDate, stockID, qty, price, sold) + holdings = append(holdings, holding) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating holding rows: %w", err) + } + + return holdings, nil +} + +// GetSoldByStockID returns sold holdings for a specific stock ID +func (s *sqliteHoldingRepo) GetSoldByStockID(stockID int64) ([]*models.Holding, error) { + query := ` + SELECT p.id, p.purchase_date, p.stock_id, p.qty, p.price, + COALESCE(SUM(t.qty), 0) as sold_qty + FROM purchases p + LEFT JOIN transactions t ON p.id = t.purchase_id + WHERE p.stock_id = ? + GROUP BY p.id + HAVING p.qty <= COALESCE(SUM(t.qty), 0) + ORDER BY p.purchase_date DESC + ` + rows, err := s.db.Query(query, stockID) + if err != nil { + return nil, fmt.Errorf("failed to get sold holdings by stock ID: %w", err) + } + defer rows.Close() + + var holdings []*models.Holding + for rows.Next() { + var id int64 + var purchaseDate time.Time + var stockID int64 + var qty float64 + var price float64 + var sold sql.NullFloat64 + + err := rows.Scan(&id, &purchaseDate, &stockID, &qty, &price, &sold) + if err != nil { + return nil, fmt.Errorf("failed to scan holding row: %w", err) + } + + holding := s.purchaseToHolding(id, purchaseDate, stockID, qty, price, sold) + holdings = append(holdings, holding) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating holding rows: %w", err) + } + + return holdings, nil +} diff --git a/server/repository/sqlitePurchase.go b/server/repository/sqlitePurchase.go new file mode 100644 index 0000000..3f9230b --- /dev/null +++ b/server/repository/sqlitePurchase.go @@ -0,0 +1,226 @@ +package repository + +import ( + "database/sql" + "errors" + "fmt" + "time" + models "vibeStonk/server/models/v1" + + "google.golang.org/protobuf/types/known/timestamppb" +) + +var ( + ErrPurchaseNotFound = errors.New("purchase not found") +) + +func newSqlitePurchaseRepo(db *sql.DB) (PurchaseRepo, error) { + repo := &sqlitePurchaseRepo{db: db} + if err := repo.initialize(); err != nil { + return nil, err + } + return repo, nil +} + +type sqlitePurchaseRepo struct { + db *sql.DB +} + +// initialize creates the purchases table if it doesn't exist +func (s *sqlitePurchaseRepo) initialize() error { + query := ` + CREATE TABLE IF NOT EXISTS purchases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + purchase_date TIMESTAMP NOT NULL, + stock_id INTEGER NOT NULL, + qty REAL NOT NULL, + price REAL NOT NULL, + FOREIGN KEY (stock_id) REFERENCES stocks(id) + ); + ` + _, err := s.db.Exec(query) + if err != nil { + return fmt.Errorf("failed to create purchases table: %w", err) + } + + query = ` + CREATE INDEX IF NOT EXISTS purchases_stock_id + ON purchases(stock_id); + ` + _, err = s.db.Exec(query) + if err != nil { + return fmt.Errorf("failed to create stock_id index for purchases table: %w", err) + } + + return nil +} + +func (s *sqlitePurchaseRepo) Create(purchase *models.Purchase) (*models.Purchase, error) { + query := ` + INSERT INTO purchases (purchase_date, stock_id, qty, price) + VALUES (?, ?, ?, ?) + ` + // Convert protobuf timestamp to time.Time for SQLite + var purchaseDate time.Time + if purchase.PurchaseDate != nil { + purchaseDate = purchase.PurchaseDate.AsTime() + } else { + purchaseDate = time.Now() + purchase.PurchaseDate = timestamppb.New(purchaseDate) + } + + result, err := s.db.Exec(query, purchaseDate, purchase.StockID, purchase.Qty, purchase.Price) + if err != nil { + return nil, fmt.Errorf("failed to create purchase: %w", err) + } + + id, err := result.LastInsertId() + if err != nil { + return nil, fmt.Errorf("failed to get last insert ID: %w", err) + } + + purchase.Id = id + return purchase, nil +} + +func (s *sqlitePurchaseRepo) Get(id int64) (*models.Purchase, error) { + query := ` + SELECT id, purchase_date, stock_id, qty, price + FROM purchases + WHERE id = ? + ` + row := s.db.QueryRow(query, id) + + purchase := &models.Purchase{} + var purchaseDate time.Time + err := row.Scan(&purchase.Id, &purchaseDate, &purchase.StockID, &purchase.Qty, &purchase.Price) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrPurchaseNotFound + } + return nil, fmt.Errorf("failed to get purchase: %w", err) + } + + // Convert time.Time to protobuf timestamp + purchase.PurchaseDate = timestamppb.New(purchaseDate) + + return purchase, nil +} + +func (s *sqlitePurchaseRepo) GetByStockID(stockID int64) ([]*models.Purchase, error) { + query := ` + SELECT id, purchase_date, stock_id, qty, price + FROM purchases + WHERE stock_id = ? + ` + rows, err := s.db.Query(query, stockID) + if err != nil { + return nil, fmt.Errorf("failed to get purchases by stock ID: %w", err) + } + defer rows.Close() + + var purchases []*models.Purchase + for rows.Next() { + purchase := &models.Purchase{} + var purchaseDate time.Time + err := rows.Scan(&purchase.Id, &purchaseDate, &purchase.StockID, &purchase.Qty, &purchase.Price) + if err != nil { + return nil, fmt.Errorf("failed to scan purchase row: %w", err) + } + // Convert time.Time to protobuf timestamp + purchase.PurchaseDate = timestamppb.New(purchaseDate) + purchases = append(purchases, purchase) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating purchase rows: %w", err) + } + + return purchases, nil +} + +func (s *sqlitePurchaseRepo) Update(purchase *models.Purchase) error { + query := ` + UPDATE purchases + SET purchase_date = ?, stock_id = ?, qty = ?, price = ? + WHERE id = ? + ` + // Convert protobuf timestamp to time.Time for SQLite + var purchaseDate time.Time + if purchase.PurchaseDate != nil { + purchaseDate = purchase.PurchaseDate.AsTime() + } else { + purchaseDate = time.Now() + purchase.PurchaseDate = timestamppb.New(purchaseDate) + } + + result, err := s.db.Exec(query, purchaseDate, purchase.StockID, purchase.Qty, purchase.Price, purchase.Id) + if err != nil { + return fmt.Errorf("failed to update purchase: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get rows affected: %w", err) + } + + if rowsAffected == 0 { + return ErrPurchaseNotFound + } + + return nil +} + +func (s *sqlitePurchaseRepo) Delete(purchase *models.Purchase) error { + query := ` + DELETE FROM purchases + WHERE id = ? + ` + result, err := s.db.Exec(query, purchase.Id) + if err != nil { + return fmt.Errorf("failed to delete purchase: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get rows affected: %w", err) + } + + if rowsAffected == 0 { + return ErrPurchaseNotFound + } + + return nil +} + +func (s *sqlitePurchaseRepo) List() ([]*models.Purchase, error) { + query := ` + SELECT id, purchase_date, stock_id, qty, price + FROM purchases + ORDER BY purchase_date DESC + ` + rows, err := s.db.Query(query) + if err != nil { + return nil, fmt.Errorf("failed to list purchases: %w", err) + } + defer rows.Close() + + var purchases []*models.Purchase + for rows.Next() { + purchase := &models.Purchase{} + var purchaseDate time.Time + err := rows.Scan(&purchase.Id, &purchaseDate, &purchase.StockID, &purchase.Qty, &purchase.Price) + if err != nil { + return nil, fmt.Errorf("failed to scan purchase row: %w", err) + } + // Convert time.Time to protobuf timestamp + purchase.PurchaseDate = timestamppb.New(purchaseDate) + purchases = append(purchases, purchase) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating purchase rows: %w", err) + } + + return purchases, nil +} diff --git a/server/repository/sqliteSale.go b/server/repository/sqliteSale.go new file mode 100644 index 0000000..935164c --- /dev/null +++ b/server/repository/sqliteSale.go @@ -0,0 +1,206 @@ +package repository + +import ( + "database/sql" + "errors" + "fmt" + "time" + models "vibeStonk/server/models/v1" + + "google.golang.org/protobuf/types/known/timestamppb" +) + +var ( + ErrSaleNotFound = errors.New("sale not found") +) + +func newSqliteSaleRepo(db *sql.DB) (SaleRepo, error) { + repo := &sqliteSaleRepo{db: db} + if err := repo.initialize(); err != nil { + return nil, err + } + return repo, nil +} + +type sqliteSaleRepo struct { + db *sql.DB +} + +// initialize creates the sales table if it doesn't exist +func (s *sqliteSaleRepo) initialize() error { + query := ` + CREATE TABLE IF NOT EXISTS sales ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sale_date TIMESTAMP NOT NULL, + stock_id INTEGER NOT NULL, + qty REAL NOT NULL, + price REAL NOT NULL + ); + ` + _, err := s.db.Exec(query) + if err != nil { + return fmt.Errorf("failed to create sales table: %w", err) + } + + query = ` + CREATE INDEX IF NOT EXISTS sales_stock_id + ON sales(stock_id); + ` + _, err = s.db.Exec(query) + if err != nil { + return fmt.Errorf("failed to create stock_id index for sales table: %w", err) + } + + return nil +} + +func (s *sqliteSaleRepo) BeginSale(stockID int, saleDate time.Time, qty, price float64) (*models.Sale, *sql.Tx, error) { + query := ` + INSERT INTO sales (sale_date, stock_id, qty, price) + VALUES (?, ?, ?, ?) + ` + + tx, err := s.db.Begin() + if err != nil { + return nil, nil, fmt.Errorf("failed to begin database tx: %w", err) + } + + result, err := s.db.Exec(query, saleDate, stockID, qty, price) + if err != nil { + tx.Rollback() + return nil, nil, fmt.Errorf("failed to create sale: %w", err) + } + + id, err := result.LastInsertId() + if err != nil { + tx.Rollback() + return nil, nil, fmt.Errorf("failed to get last insert ID: %w", err) + } + + return &models.Sale{ + Id: id, + SaleDate: timestamppb.New(saleDate), + StockID: int64(stockID), + Qty: qty, + Price: price, + }, tx, nil +} + +func (s *sqliteSaleRepo) Get(id int64) (*models.Sale, error) { + query := ` + SELECT id, sale_date, stock_id, qty, price + FROM sales + WHERE id = ? + ` + row := s.db.QueryRow(query, id) + + sale := &models.Sale{} + var saleDate time.Time + err := row.Scan(&sale.Id, &saleDate, &sale.StockID, &sale.Qty, &sale.Price) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrSaleNotFound + } + return nil, fmt.Errorf("failed to get sale: %w", err) + } + + // Convert time.Time to protobuf timestamp + sale.SaleDate = timestamppb.New(saleDate) + + return sale, nil +} + +func (s *sqliteSaleRepo) GetByStockID(stockID int64) ([]*models.Sale, error) { + query := ` + SELECT id, sale_date, stock_id, qty, price + FROM sales + WHERE stock_id = ? + ` + rows, err := s.db.Query(query, stockID) + if err != nil { + return nil, fmt.Errorf("failed to get sales by stock ID: %w", err) + } + defer rows.Close() + + var sales []*models.Sale + for rows.Next() { + sale := &models.Sale{} + var saleDate time.Time + err := rows.Scan(&sale.Id, &saleDate, &sale.StockID, &sale.Qty, &sale.Price) + if err != nil { + return nil, fmt.Errorf("failed to scan sale row: %w", err) + } + // Convert time.Time to protobuf timestamp + sale.SaleDate = timestamppb.New(saleDate) + sales = append(sales, sale) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating sale rows: %w", err) + } + + return sales, nil +} + +func (s *sqliteSaleRepo) BeginDelete(sale *models.Sale) (*sql.Tx, error) { + query := ` + DELETE FROM sales + WHERE id = ? + ` + + tx, err := s.db.Begin() + if err != nil { + return nil, fmt.Errorf("failed to begin database transaction: %w", err) + } + + result, err := s.db.Exec(query, sale.Id) + if err != nil { + tx.Rollback() + return nil, fmt.Errorf("failed to delete sale: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + tx.Rollback() + return nil, fmt.Errorf("failed to get rows affected: %w", err) + } + + if rowsAffected == 0 { + tx.Rollback() + return nil, ErrSaleNotFound + } + + return tx, nil +} + +func (s *sqliteSaleRepo) List() ([]*models.Sale, error) { + query := ` + SELECT id, sale_date, stock_id, qty, price + FROM sales + ORDER BY sale_date DESC + ` + rows, err := s.db.Query(query) + if err != nil { + return nil, fmt.Errorf("failed to list sales: %w", err) + } + defer rows.Close() + + var sales []*models.Sale + for rows.Next() { + sale := &models.Sale{} + var saleDate time.Time + err := rows.Scan(&sale.Id, &saleDate, &sale.StockID, &sale.Qty, &sale.Price) + if err != nil { + return nil, fmt.Errorf("failed to scan sale row: %w", err) + } + // Convert time.Time to protobuf timestamp + sale.SaleDate = timestamppb.New(saleDate) + sales = append(sales, sale) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating sale rows: %w", err) + } + + return sales, nil +} diff --git a/server/repository/sqliteSaleFragment.go b/server/repository/sqliteSaleFragment.go new file mode 100644 index 0000000..28d1468 --- /dev/null +++ b/server/repository/sqliteSaleFragment.go @@ -0,0 +1,88 @@ +package repository + +import ( + "database/sql" + "fmt" + "time" + models "vibeStonk/server/models/v1" + + "google.golang.org/protobuf/types/known/timestamppb" +) + +func newSqliteSaleFragmentRepo(db *sql.DB) SaleFragmentRepo { + return &sqliteSaleFragmentRepo{db: db} +} + +type sqliteSaleFragmentRepo struct { + db *sql.DB +} + +func (s *sqliteSaleFragmentRepo) GetBySaleID(saleID int64) ([]*models.SaleFragment, error) { + query := ` + SELECT t.id, t.qty, p.price as purchase_price, s.price as sale_price, p.purchase_date, s.sale_date + FROM transactions t + JOIN purchases p ON t.purchase_id = p.id + JOIN sales s ON t.sale_id = s.id + WHERE t.sale_id = ? + ` + rows, err := s.db.Query(query, saleID) + if err != nil { + return nil, fmt.Errorf("failed to get sale fragments by sale ID: %w", err) + } + defer rows.Close() + + var fragments []*models.SaleFragment + for rows.Next() { + fragment := &models.SaleFragment{} + var purchaseDate, saleDate time.Time + err := rows.Scan(&fragment.Id, &fragment.Qty, &fragment.PurchasePrice, &fragment.SalePrice, &purchaseDate, &saleDate) + if err != nil { + return nil, fmt.Errorf("failed to scan sale fragment row: %w", err) + } + // Convert time.Time to protobuf timestamp + fragment.PurchaseDate = timestamppb.New(purchaseDate) + fragment.SaleDate = timestamppb.New(saleDate) + fragments = append(fragments, fragment) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating sale fragment rows: %w", err) + } + + return fragments, nil +} + +func (s *sqliteSaleFragmentRepo) GetByPurchaseID(purchaseID int64) ([]*models.SaleFragment, error) { + query := ` + SELECT t.id, t.qty, p.price as purchase_price, s.price as sale_price, p.purchase_date, s.sale_date + FROM transactions t + JOIN purchases p ON t.purchase_id = p.id + JOIN sales s ON t.sale_id = s.id + WHERE t.purchase_id = ? + ` + rows, err := s.db.Query(query, purchaseID) + if err != nil { + return nil, fmt.Errorf("failed to get sale fragments by purchase ID: %w", err) + } + defer rows.Close() + + var fragments []*models.SaleFragment + for rows.Next() { + fragment := &models.SaleFragment{} + var purchaseDate, saleDate time.Time + err := rows.Scan(&fragment.Id, &fragment.Qty, &fragment.PurchasePrice, &fragment.SalePrice, &purchaseDate, &saleDate) + if err != nil { + return nil, fmt.Errorf("failed to scan sale fragment row: %w", err) + } + // Convert time.Time to protobuf timestamp + fragment.PurchaseDate = timestamppb.New(purchaseDate) + fragment.SaleDate = timestamppb.New(saleDate) + fragments = append(fragments, fragment) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating sale fragment rows: %w", err) + } + + return fragments, nil +} diff --git a/server/repository/sqliteSession.go b/server/repository/sqliteSession.go new file mode 100644 index 0000000..bca6127 --- /dev/null +++ b/server/repository/sqliteSession.go @@ -0,0 +1,165 @@ +package repository + +import ( + "database/sql" + "errors" + "fmt" + "github.com/google/uuid" + "google.golang.org/protobuf/types/known/timestamppb" + "time" + models "vibeStonk/server/models/v1" +) + +var ( + ErrSessionNotFound = errors.New("session not found") +) + +func newSqliteSessionRepo(db *sql.DB) SessionRepo { + repo := &sqliteSessionRepo{db: db} + if err := repo.initialize(); err != nil { + // Since we can't return an error from this function, we'll panic + // In a production environment, this should be handled differently + panic(fmt.Sprintf("failed to initialize session repository: %v", err)) + } + return repo +} + +type sqliteSessionRepo struct { + db *sql.DB +} + +// initialize creates the sessions table if it doesn't exist +func (s *sqliteSessionRepo) initialize() error { + query := ` + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + revoked BOOLEAN NOT NULL DEFAULT 0, + token TEXT UNIQUE NOT NULL, + created TIMESTAMP NOT NULL, + expires TIMESTAMP NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) + ); + ` + _, err := s.db.Exec(query) + if err != nil { + return fmt.Errorf("failed to create sessions table: %w", err) + } + + query = ` + CREATE INDEX IF NOT EXISTS sessions_token + ON sessions(token); + ` + _, err = s.db.Exec(query) + if err != nil { + return fmt.Errorf("failed to create token index for sessions table: %w", err) + } + + return nil +} + +func (s *sqliteSessionRepo) Create(user *models.User) (*models.Session, error) { + // Generate a unique ID and token for the session + id := uuid.New().String() + token := uuid.New().String() + + // Set creation time to now and expiration to 24 hours from now + now := time.Now() + expires := now.Add(24 * time.Hour) + + // Create the session object + session := &models.Session{ + Id: id, + UserID: user.Id, + Revoked: false, + Token: token, + Created: timestamppb.New(now), + Expires: timestamppb.New(expires), + } + + // Insert the session into the database + query := ` + INSERT INTO sessions (id, user_id, revoked, token, created, expires) + VALUES (?, ?, ?, ?, ?, ?) + ` + _, err := s.db.Exec(query, session.Id, session.UserID, session.Revoked, session.Token, + session.Created.AsTime(), session.Expires.AsTime()) + if err != nil { + return nil, fmt.Errorf("failed to create session: %w", err) + } + + return session, nil +} + +func (s *sqliteSessionRepo) Get(token string) (*models.Session, error) { + query := ` + SELECT id, user_id, revoked, token, created, expires + FROM sessions + WHERE token = ? + ` + row := s.db.QueryRow(query, token) + + session := &models.Session{} + var createdTime, expiresTime time.Time + + err := row.Scan(&session.Id, &session.UserID, &session.Revoked, &session.Token, &createdTime, &expiresTime) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrSessionNotFound + } + return nil, fmt.Errorf("failed to get session: %w", err) + } + + // Convert time.Time to protobuf Timestamp + session.Created = timestamppb.New(createdTime) + session.Expires = timestamppb.New(expiresTime) + + return session, nil +} + +func (s *sqliteSessionRepo) Revoke(session *models.Session) error { + query := ` + UPDATE sessions + SET revoked = 1 + WHERE id = ? + ` + result, err := s.db.Exec(query, session.Id) + if err != nil { + return fmt.Errorf("failed to revoke session: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get rows affected: %w", err) + } + + if rowsAffected == 0 { + return ErrSessionNotFound + } + + // BeginUpdate the session object to reflect the change + session.Revoked = true + + return nil +} + +func (s *sqliteSessionRepo) DeleteExpired() error { + query := ` + DELETE FROM sessions + WHERE expires < ? + ` + result, err := s.db.Exec(query, time.Now()) + if err != nil { + return fmt.Errorf("failed to delete expired sessions: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get rows affected: %w", err) + } + + // Log the number of deleted sessions (in a real application, you might want to use a logger) + fmt.Printf("Deleted %d expired sessions\n", rowsAffected) + + return nil +} diff --git a/server/repository/sqliteStock.go b/server/repository/sqliteStock.go new file mode 100644 index 0000000..9156c94 --- /dev/null +++ b/server/repository/sqliteStock.go @@ -0,0 +1,228 @@ +package repository + +import ( + "database/sql" + "errors" + "fmt" + "strings" + models "vibeStonk/server/models/v1" +) + +var ( + ErrStockNotFound = errors.New("stock not found") +) + +func newSqliteStockRepo(db *sql.DB) (StockRepo, error) { + repo := &sqliteStockRepo{db: db} + if err := repo.initialize(); err != nil { + return nil, err + } + return repo, nil +} + +type sqliteStockRepo struct { + db *sql.DB +} + +// initialize creates the stocks table if it doesn't exist +func (s *sqliteStockRepo) initialize() error { + query := ` + CREATE TABLE IF NOT EXISTS stocks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + symbol TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + color TEXT + ); + ` + _, err := s.db.Exec(query) + if err != nil { + return fmt.Errorf("failed to create stocks table: %w", err) + } + + query = ` + CREATE INDEX IF NOT EXISTS stocks_symbols + ON stocks(symbol); + ` + _, err = s.db.Exec(query) + if err != nil { + return fmt.Errorf("failed to create symbol index for stocks table: %w", err) + } + + return nil +} + +func (s *sqliteStockRepo) Create(stock *models.Stock) (*models.Stock, error) { + query := ` + INSERT INTO stocks (symbol, name, color) + VALUES (?, ?, ?) + ` + result, err := s.db.Exec(query, stock.Symbol, stock.Name, stock.Color) + if err != nil { + return nil, fmt.Errorf("failed to create stock: %w", err) + } + + id, err := result.LastInsertId() + if err != nil { + return nil, fmt.Errorf("failed to get last insert ID: %w", err) + } + + stock.Id = id + return stock, nil +} + +func (s *sqliteStockRepo) Get(id int64) (*models.Stock, error) { + query := ` + SELECT id, symbol, name, color + FROM stocks + WHERE id = ? + ` + row := s.db.QueryRow(query, id) + + stock := &models.Stock{} + err := row.Scan(&stock.Id, &stock.Symbol, &stock.Name, &stock.Color) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrStockNotFound + } + return nil, fmt.Errorf("failed to get stock: %w", err) + } + + return stock, nil +} + +func (s *sqliteStockRepo) GetBySymbol(symbol string) (*models.Stock, error) { + query := ` + SELECT id, symbol, name, color + FROM stocks + WHERE symbol = ? + ` + row := s.db.QueryRow(query, symbol) + + stock := &models.Stock{} + err := row.Scan(&stock.Id, &stock.Symbol, &stock.Name, &stock.Color) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrStockNotFound + } + return nil, fmt.Errorf("failed to get stock by symbol: %w", err) + } + + return stock, nil +} + +func (s *sqliteStockRepo) Update(stock *models.Stock) error { + query := ` + UPDATE stocks + SET symbol = ?, name = ?, color = ? + WHERE id = ? + ` + result, err := s.db.Exec(query, stock.Symbol, stock.Name, stock.Color, stock.Id) + if err != nil { + return fmt.Errorf("failed to update stock: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get rows affected: %w", err) + } + + if rowsAffected == 0 { + return ErrStockNotFound + } + + return nil +} + +func (s *sqliteStockRepo) Delete(stock *models.Stock) error { + query := ` + DELETE FROM stocks + WHERE id = ? + ` + result, err := s.db.Exec(query, stock.Id) + if err != nil { + return fmt.Errorf("failed to delete stock: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get rows affected: %w", err) + } + + if rowsAffected == 0 { + return ErrStockNotFound + } + + return nil +} + +func (s *sqliteStockRepo) List() ([]*models.Stock, error) { + query := ` + SELECT id, symbol, name, color + FROM stocks + ORDER BY symbol + ` + rows, err := s.db.Query(query) + if err != nil { + return nil, fmt.Errorf("failed to list stocks: %w", err) + } + defer rows.Close() + + var stocks []*models.Stock + for rows.Next() { + stock := &models.Stock{} + err := rows.Scan(&stock.Id, &stock.Symbol, &stock.Name, &stock.Color) + if err != nil { + return nil, fmt.Errorf("failed to scan stock row: %w", err) + } + stocks = append(stocks, stock) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating stock rows: %w", err) + } + + return stocks, nil +} + +func (s *sqliteStockRepo) GetByIDs(ids []int) ([]*models.Stock, error) { + query := buildGetByIDsQuery(ids) + params := make([]interface{}, 0, len(ids)) + for _, id := range ids { + params = append(params, id) + } + + rows, err := s.db.Query(query, params...) + if err != nil { + return nil, fmt.Errorf("failed to list stocks: %w", err) + } + defer rows.Close() + + var stocks []*models.Stock + for rows.Next() { + stock := &models.Stock{} + err := rows.Scan(&stock.Id, &stock.Symbol, &stock.Name, &stock.Color) + if err != nil { + return nil, fmt.Errorf("failed to scan stock row: %w", err) + } + stocks = append(stocks, stock) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating stock rows: %w", err) + } + + return stocks, nil +} + +func buildGetByIDsQuery(ids []int) string { + sb := &strings.Builder{} + sb.WriteString("SELECT id, symbol, name, color FROM stocks WHERE id in (") + for i := range ids { + sb.WriteByte('?') + if i < len(ids)-1 { + sb.WriteByte(',') + } + } + sb.WriteString(") ORDER BY symbol") + return sb.String() +} diff --git a/server/repository/sqliteTransaction.go b/server/repository/sqliteTransaction.go new file mode 100644 index 0000000..eac4d77 --- /dev/null +++ b/server/repository/sqliteTransaction.go @@ -0,0 +1,200 @@ +package repository + +import ( + "database/sql" + "errors" + "fmt" + models "vibeStonk/server/models/v1" +) + +var ( + ErrTransactionNotFound = errors.New("transaction not found") +) + +func newSqliteTransactionRepo(db *sql.DB) (TransactionRepo, error) { + repo := &sqliteTransactionRepo{db: db} + if err := repo.initialize(); err != nil { + return nil, err + } + return repo, nil +} + +type sqliteTransactionRepo struct { + db *sql.DB +} + +// initialize creates the transactions table if it doesn't exist +func (s *sqliteTransactionRepo) initialize() error { + query := ` + CREATE TABLE IF NOT EXISTS transactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + purchase_id INTEGER NOT NULL, + sale_id INTEGER NOT NULL, + qty REAL NOT NULL, + FOREIGN KEY (purchase_id) REFERENCES purchases(id), + FOREIGN KEY (sale_id) REFERENCES sales(id) + ); + ` + _, err := s.db.Exec(query) + if err != nil { + return fmt.Errorf("failed to create transactions table: %w", err) + } + + query = ` + CREATE INDEX IF NOT EXISTS transactions_foreign_id + ON transactions(purchase_id, sale_id); + ` + _, err = s.db.Exec(query) + if err != nil { + return fmt.Errorf("failed to create purchase_id index for transactions table: %w", err) + } + + return nil +} + +func (s *sqliteTransactionRepo) Create(transaction *models.Transaction) (*models.Transaction, error) { + query := ` + INSERT INTO transactions (purchase_id, sale_id, qty) + VALUES (?, ?, ?) + ` + result, err := s.db.Exec(query, transaction.PurchaseID, transaction.SaleID, transaction.Qty) + if err != nil { + return nil, fmt.Errorf("failed to create transaction: %w", err) + } + + id, err := result.LastInsertId() + if err != nil { + return nil, fmt.Errorf("failed to get last insert ID: %w", err) + } + + transaction.Id = id + return transaction, nil +} + +func (s *sqliteTransactionRepo) Get(id int64) (*models.Transaction, error) { + query := ` + SELECT id, purchase_id, sale_id, qty + FROM transactions + WHERE id = ? + ` + row := s.db.QueryRow(query, id) + + transaction := &models.Transaction{} + err := row.Scan(&transaction.Id, &transaction.PurchaseID, &transaction.SaleID, &transaction.Qty) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrTransactionNotFound + } + return nil, fmt.Errorf("failed to get transaction: %w", err) + } + + return transaction, nil +} + +func (s *sqliteTransactionRepo) GetByPurchaseID(purchaseID int64) ([]*models.Transaction, error) { + query := ` + SELECT id, purchase_id, sale_id, qty + FROM transactions + WHERE purchase_id = ? + ` + rows, err := s.db.Query(query, purchaseID) + if err != nil { + return nil, fmt.Errorf("failed to get transactions by purchase ID: %w", err) + } + defer rows.Close() + + var transactions []*models.Transaction + for rows.Next() { + transaction := &models.Transaction{} + err := rows.Scan(&transaction.Id, &transaction.PurchaseID, &transaction.SaleID, &transaction.Qty) + if err != nil { + return nil, fmt.Errorf("failed to scan transaction row: %w", err) + } + transactions = append(transactions, transaction) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating transaction rows: %w", err) + } + + return transactions, nil +} + +func (s *sqliteTransactionRepo) GetBySaleID(saleID int64) ([]*models.Transaction, error) { + query := ` + SELECT id, purchase_id, sale_id, qty + FROM transactions + WHERE sale_id = ? + ` + rows, err := s.db.Query(query, saleID) + if err != nil { + return nil, fmt.Errorf("failed to get transactions by sale ID: %w", err) + } + defer rows.Close() + + var transactions []*models.Transaction + for rows.Next() { + transaction := &models.Transaction{} + err := rows.Scan(&transaction.Id, &transaction.PurchaseID, &transaction.SaleID, &transaction.Qty) + if err != nil { + return nil, fmt.Errorf("failed to scan transaction row: %w", err) + } + transactions = append(transactions, transaction) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating transaction rows: %w", err) + } + + return transactions, nil +} + +func (s *sqliteTransactionRepo) DeleteBySaleID(saleID int64) error { + query := ` + DELETE FROM transactions + WHERE sale_id = ? + ` + result, err := s.db.Exec(query, saleID) + if err != nil { + return fmt.Errorf("failed to delete transaction: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get rows affected: %w", err) + } + + if rowsAffected == 0 { + return ErrTransactionNotFound + } + + return nil +} + +func (s *sqliteTransactionRepo) List() ([]*models.Transaction, error) { + query := ` + SELECT id, purchase_id, sale_id, qty + FROM transactions + ` + rows, err := s.db.Query(query) + if err != nil { + return nil, fmt.Errorf("failed to list transactions: %w", err) + } + defer rows.Close() + + var transactions []*models.Transaction + for rows.Next() { + transaction := &models.Transaction{} + err := rows.Scan(&transaction.Id, &transaction.PurchaseID, &transaction.SaleID, &transaction.Qty) + if err != nil { + return nil, fmt.Errorf("failed to scan transaction row: %w", err) + } + transactions = append(transactions, transaction) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating transaction rows: %w", err) + } + + return transactions, nil +} diff --git a/server/repository/sqliteUsers.go b/server/repository/sqliteUsers.go new file mode 100644 index 0000000..7cec214 --- /dev/null +++ b/server/repository/sqliteUsers.go @@ -0,0 +1,152 @@ +package repository + +import ( + "database/sql" + "errors" + "fmt" + models "vibeStonk/server/models/v1" +) + +var ( + ErrUserNotFound = errors.New("user not found") +) + +func newSqliteUsersRepo(db *sql.DB) (UserRepo, error) { + repo := &sqliteUsersRepo{db: db} + if err := repo.initialize(); err != nil { + return nil, err + } + return repo, nil +} + +type sqliteUsersRepo struct { + db *sql.DB +} + +// initialize creates the users table if it doesn't exist +func (s *sqliteUsersRepo) initialize() error { + query := ` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + pref_name TEXT, + hash TEXT + ); + ` + _, err := s.db.Exec(query) + if err != nil { + return fmt.Errorf("failed to create user table: %w", err) + } + + query = ` + CREATE INDEX IF NOT EXISTS users_usernames + ON users(id, username); +` + _, err = s.db.Exec(query) + if err != nil { + return fmt.Errorf("failed to create username index for users table: %w", err) + } + + return err +} + +func (s *sqliteUsersRepo) Create(user *models.User) (*models.User, error) { + // Note: The hash field is in the proto definition but not in the generated struct + // In a real implementation, we would need to handle this differently + query := ` + INSERT INTO users (id, username, pref_name, hash) + VALUES (?, ?, ?, ?) + ` + _, err := s.db.Exec(query, user.Id, user.UserName, user.PrefName, user.Hash) + if err != nil { + return nil, err + } + return user, nil +} + +func (s *sqliteUsersRepo) Get(id string) (*models.User, error) { + query := ` + SELECT id, username, pref_name, hash + FROM users + WHERE id = ? + ` + row := s.db.QueryRow(query, id) + + user := &models.User{} + err := row.Scan(&user.Id, &user.UserName, &user.PrefName, &user.Hash) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrUserNotFound + } + return nil, err + } + + return user, nil +} + +func (s *sqliteUsersRepo) GetByUsername(username string) (*models.User, error) { + query := ` + SELECT id, username, pref_name, hash + FROM users + WHERE username = ? + ` + row := s.db.QueryRow(query, username) + + user := &models.User{} + err := row.Scan(&user.Id, &user.UserName, &user.PrefName, &user.Hash) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrUserNotFound + } + return nil, err + } + + return user, nil +} + +func (s *sqliteUsersRepo) Update(user *models.User) error { + // Note: The hash field is in the proto definition but not in the generated struct + // In a real implementation, we would need to handle this differently + query := ` + UPDATE users + SET username = ?, pref_name = ? + WHERE id = ? + ` + result, err := s.db.Exec(query, user.UserName, user.PrefName, user.Id) + if err != nil { + return err + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return err + } + + if rowsAffected == 0 { + return ErrUserNotFound + } + + return nil +} + +func (s *sqliteUsersRepo) Delete(user *models.User) error { + query := ` + DELETE FROM users + WHERE id = ? + ` + result, err := s.db.Exec(query, user.Id) + if err != nil { + return err + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return err + } + + if rowsAffected == 0 { + return ErrUserNotFound + } + + return nil +} diff --git a/server/routes/route.go b/server/routes/route.go new file mode 100644 index 0000000..1c4b19e --- /dev/null +++ b/server/routes/route.go @@ -0,0 +1,67 @@ +package routes + +import ( + "errors" + "github.com/labstack/echo/v4" + models "vibeStonk/server/models/v1" +) + +type Provider interface { + Provide(middlewares *SystemMiddleware) []*Route +} + +type Route struct { + Endpoint Endpoint + Method string + HandlerFn echo.HandlerFunc + Middleware []echo.MiddlewareFunc +} + +// region Endpoint + +type Endpoint string + +func (e Endpoint) String() string { + return string(e) +} + +func endpoint(endpoint string) Endpoint { + return Endpoint("/api/v1" + endpoint) +} + +// endregion + +// region request context + +const requestContextKey = "request_context" + +var ( + ErrNoRequestContext = errors.New("no request context found") + ErrBadRequestContext = errors.New("context found, but wrong type") +) + +type RequestContext struct { + User *models.User +} + +func newRequestContext(c echo.Context, user *models.User) { + ctx := &RequestContext{ + User: user, + } + c.Set(requestContextKey, ctx) +} + +func GetRequestContext(c echo.Context) (*RequestContext, error) { + ctxInterface := c.Get(requestContextKey) + if ctxInterface == nil { + return nil, ErrNoRequestContext + } + + ctx, ok := ctxInterface.(*RequestContext) + if !ok { + return nil, ErrBadRequestContext + } + return ctx, nil +} + +// endregion diff --git a/server/routes/routeHealth.go b/server/routes/routeHealth.go new file mode 100644 index 0000000..e904567 --- /dev/null +++ b/server/routes/routeHealth.go @@ -0,0 +1,50 @@ +package routes + +import ( + "fmt" + "github.com/labstack/echo/v4" + "google.golang.org/protobuf/proto" + "net/http" + models "vibeStonk/server/models/v1" +) + +type healthRoute struct { +} + +func NewHealthRoute() Provider { + return &healthRoute{} +} + +func (h *healthRoute) Provide(_ *SystemMiddleware) []*Route { + return []*Route{ + {endpoint("/health"), http.MethodGet, h.handleGet, nil}, + {endpoint("/health"), http.MethodPost, h.handlePost, nil}, + } +} + +func (h *healthRoute) handleGet(c echo.Context) error { + resp := &models.ServerStatus{Status: "okay"} + + payload, err := proto.Marshal(resp) + if err != nil { + return fmt.Errorf("failed to serialize health response: %w", err) + } + + c.Response().Header().Set("Content-Type", "application/x-protobuf") + _, err = c.Response().Write(payload) + if err != nil { + return fmt.Errorf("failed to write health response: %w", err) + } + + return nil +} + +func (h *healthRoute) handlePost(c echo.Context) error { + name := c.Request().URL.Query().Get("name") + err := c.String(200, name) + if err != nil { + return fmt.Errorf("failed to write response: %w", err) + } + + return nil +} diff --git a/server/routes/routeUser.go b/server/routes/routeUser.go new file mode 100644 index 0000000..95cd381 --- /dev/null +++ b/server/routes/routeUser.go @@ -0,0 +1,268 @@ +package routes + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "github.com/labstack/echo/v4" + "google.golang.org/protobuf/proto" + "io" + "net/http" + models "vibeStonk/server/models/v1" + "vibeStonk/server/services" +) + +var ( + errNoUsername = errors.New("missing username") + errNoPrefname = errors.New("missing preferred name") + errNoPassword = errors.New("missing password") + errMismatched = errors.New("passwords mismatched") +) + +func NewUserRoute(system *services.SystemServices) UserRoute { + return &userRoute{system: system} +} + +type UserRoute interface { + Provide(middlewares *SystemMiddleware) []*Route +} + +type userRoute struct { + system *services.SystemServices +} + +func (u *userRoute) Provide(middlewares *SystemMiddleware) []*Route { + return []*Route{ + {endpoint("/user"), http.MethodGet, u.handleGet, []echo.MiddlewareFunc{middlewares.UserAuth}}, + {endpoint("/user"), http.MethodPost, u.handlePost, nil}, + {endpoint("/user/login"), http.MethodPost, u.handleLogin, nil}, + } +} + +// region GET + +func (u *userRoute) handleGet(c echo.Context) error { + // by the time we get here, we are guaranteed to have an authenticated user + rCtx, err := GetRequestContext(c) + if err != nil { + return fmt.Errorf("userRoute handleGet failed to get request context: %w", err) + } + + user := rCtx.User + user.Hash = "" + payload, err := proto.Marshal(user) + if err != nil { + return fmt.Errorf("userRoute handleGet failed to serialize response: %w", err) + } + + c.Response().Header().Set("Content-Type", "application/json") + _, err = c.Response().Write(payload) + if err != nil { + return fmt.Errorf("userRoute handleGet failed to write response: %w", err) + } + + return nil +} + +// endregion + +// region POST + +func (u *userRoute) handlePost(c echo.Context) error { + registration := &models.UserRegistration{} + bodyReader := c.Request().Body + defer bodyReader.Close() + + var err error + contentType := c.Request().Header.Get("Content-Type") + switch contentType { + case "application/json": + var bodyContent []byte + bodyContent, err = io.ReadAll(bodyReader) + if err != nil { + return fmt.Errorf("userRoute handlePost failed to read request body: %w", err) + } + + err = json.Unmarshal(bodyContent, registration) + if err != nil { + return fmt.Errorf("failed to deserialize proto request: %w", err) + } + case "application/x-protobuf": + var bodyContent []byte + bodyContent, err = io.ReadAll(bodyReader) + if err != nil { + return fmt.Errorf("userRoute handlePost failed to read request body: %w", err) + } + + err = proto.Unmarshal(bodyContent, registration) + if err != nil { + return fmt.Errorf("failed to deserialize proto request: %w", err) + } + + case "multipart/form-data; boundary=X-INSOMNIA-BOUNDARY": + // Parse the multipart form + err = c.Request().ParseMultipartForm(10 << 20) // 10 MB max memory + if err != nil { + return fmt.Errorf("failed to parse multipart form: %w", err) + } + + // Extract form fields + userName := c.FormValue("userName") + prefName := c.FormValue("prefName") + pswd := c.FormValue("pswd") + pswdConfirm := c.FormValue("pswdConfirm") + + // Populate the registration object + registration.UserName = userName + registration.PrefName = prefName + registration.Pswd = []byte(pswd) + registration.PswdConfirm = []byte(pswdConfirm) + default: + return fmt.Errorf("did not understand content-type: %s", contentType) + } + + err = validateRegistration(registration) + if err != nil { + return fmt.Errorf("failed to validate registration: %w", err) + } + + user, err := u.system.AuthService.RegisterUser(registration.UserName, registration.PrefName, registration.Pswd) + if err != nil { + return fmt.Errorf("failed to create user: %w", err) + } + + session, err := u.system.AuthService.AuthenticateUser(user.UserName, registration.Pswd) + if err != nil { + return fmt.Errorf("failed to authenticate newly-created user: %w", err) + } + + resp := &models.UserRegistrationResponse{ + User: user, + Token: session.Token, + } + + payload, err := proto.Marshal(resp) + if err != nil { + return fmt.Errorf("failed to marshal response: %w", err) + } + + _, err = c.Response().Write(payload) + if err != nil { + return fmt.Errorf("failed to write response to client: %w", err) + } + + return nil +} + +func validateRegistration(registration *models.UserRegistration) error { + if len(registration.UserName) == 0 { + return errNoUsername + } + + if len(registration.PrefName) == 0 { + return errNoPrefname + } + + if len(registration.Pswd) == 0 { + return errNoPassword + } + + if len(registration.Pswd) != len(registration.PswdConfirm) || bytes.Compare(registration.Pswd, registration.PswdConfirm) != 0 { + return errMismatched + } + + return nil +} + +// endregion + +// region LOGIN + +func (u *userRoute) handleLogin(c echo.Context) error { + login := &models.Login{} + bodyReader := c.Request().Body + defer bodyReader.Close() + + var err error + contentType := c.Request().Header.Get("Content-Type") + switch contentType { + case "application/json": + var bodyContent []byte + bodyContent, err = io.ReadAll(bodyReader) + if err != nil { + return fmt.Errorf("userRoute handleLogin failed to read request body: %w", err) + } + + err = json.Unmarshal(bodyContent, login) + if err != nil { + return fmt.Errorf("failed to deserialize json request: %w", err) + } + + case "multipart/form-data; boundary=X-INSOMNIA-BOUNDARY": + // Parse the multipart form + err = c.Request().ParseMultipartForm(10 << 20) // 10 MB max memory + if err != nil { + return fmt.Errorf("failed to parse multipart form: %w", err) + } + + // Extract form fields + login.UserName = c.FormValue("userName") + login.Password = []byte(c.FormValue("password")) + case "application/x-protobuf": + payload, err := io.ReadAll(c.Request().Body) + if err != nil { + return fmt.Errorf("failed to read protobuf request body: %w", err) + } + + err = proto.Unmarshal(payload, login) + if err != nil { + return fmt.Errorf("failed to unmarshal protobuf payload: %w", err) + } + default: + return fmt.Errorf("did not understand content-type: %s", contentType) + } + + // Validate login request + if len(login.UserName) == 0 { + return errNoUsername + } + if len(login.Password) == 0 { + return errNoPassword + } + + // Authenticate user + session, err := u.system.AuthService.AuthenticateUser(login.UserName, []byte(login.Password)) + if err != nil { + return fmt.Errorf("failed to authenticate user: %w", err) + } + + // Get user details + user, err := u.system.AuthService.AuthenticateToken(session.Token) + if err != nil { + return fmt.Errorf("failed to get user details: %w", err) + } + + // Clear password hash for security + user.Hash = "" + + // Create response + resp := &models.UserRegistrationResponse{ + User: user, + Token: session.Token, + } + + payload, err := proto.Marshal(resp) + if err != nil { + return fmt.Errorf("failed to marshal response: %w", err) + } + + _, err = c.Response().Write(payload) + if err != nil { + return fmt.Errorf("failed to write response to client: %w", err) + } + + return nil +} + +// endregion diff --git a/server/routes/server.go b/server/routes/server.go new file mode 100644 index 0000000..8e1f2bc --- /dev/null +++ b/server/routes/server.go @@ -0,0 +1,155 @@ +package routes + +import ( + "errors" + "fmt" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" + "github.com/labstack/gommon/log" + "net/http" + "vibeStonk/server/repository" + "vibeStonk/server/services" +) + +type ApiServer interface { + AddRoutesBulk(routeProviders []Provider) error + AddRoute(routeProvider Provider) error + + GetSystemServices() *services.SystemServices + GetCommonMiddleware() *SystemMiddleware + + Start() error +} + +type SystemMiddleware struct { + UserAuth echo.MiddlewareFunc +} + +func NewAPIServer(config *repository.Config) (ApiServer, error) { + e := echo.New() + e.HideBanner = true + + // Middleware + e.Use(middleware.Logger()) + e.Use(middleware.Recover()) + e.Use(middleware.CORS()) + + svcs, err := services.NewSystemServices(config) + if err != nil { + return nil, fmt.Errorf("failed to initialize server's system services: %w", err) + } + + server := &apiServer{ + e: e, + config: config, + Services: svcs, + } + + // initialize common mware utilities + mware := &SystemMiddleware{ + UserAuth: server.AuthenticationMiddleware, + } + server.Middleware = mware + + server.developmentNonsense() + return server, nil +} + +type apiServer struct { + e *echo.Echo + config *repository.Config + Services *services.SystemServices + Middleware *SystemMiddleware +} + +func (a *apiServer) addRoute(route *Route) error { + var handler func(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + switch route.Method { + case http.MethodPost: + handler = a.e.POST + case http.MethodGet: + handler = a.e.GET + case http.MethodPut: + handler = a.e.PUT + case http.MethodDelete: + handler = a.e.DELETE + default: + return fmt.Errorf("failed to add Route; unknown method: %s", route.Method) + } + + log.Infof("Registering route[%s]{%s}\n", route.Endpoint.String(), route.Method) + handler(route.Endpoint.String(), route.HandlerFn, route.Middleware...) + return nil +} + +func (a *apiServer) AddRoute(provider Provider) error { + for _, route := range provider.Provide(a.Middleware) { + err := a.addRoute(route) + if err != nil { + return fmt.Errorf("failed to add route[%s]: %w", route.Endpoint.String(), err) + } + } + + return nil +} + +func (a *apiServer) AddRoutesBulk(routeProviders []Provider) error { + for _, rp := range routeProviders { + err := a.AddRoute(rp) + if err != nil { + return err + } + } + + return nil +} + +func (a *apiServer) developmentNonsense() { + // this is here until we get a hosting | ci/cd pipeline established + a.e.Static("/", "./content/") +} + +func (a *apiServer) Start() error { + return a.e.Start(a.config.ListenAddress) +} + +func (a *apiServer) GetSystemServices() *services.SystemServices { + return a.Services +} + +func (a *apiServer) GetCommonMiddleware() *SystemMiddleware { + return a.Middleware +} + +// region system-level middleware + +const authHeader = "Bearer" + +var ( + ErrNoAuthToken = errors.New("no 'Bearer' token header") +) + +func (a *apiServer) AuthenticationMiddleware(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + token := c.Request().Header.Get(authHeader) + if len(token) == 0 { + c.Response().Status = http.StatusUnauthorized + _, err := c.Response().Write([]byte(ErrNoAuthToken.Error())) + if err != nil { + return fmt.Errorf("failed to write error response: %w", err) + } + return nil + } + + user, err := a.Services.AuthService.AuthenticateToken(token) + if err != nil { + c.Response().Status = http.StatusUnauthorized + return fmt.Errorf("failed to authenticate user's bearer token: %w", err) + } + + newRequestContext(c, user) + return next(c) + } +} + +// endregion diff --git a/server/services/authService.go b/server/services/authService.go new file mode 100644 index 0000000..92ed245 --- /dev/null +++ b/server/services/authService.go @@ -0,0 +1,135 @@ +package services + +import ( + "database/sql" + "errors" + "fmt" + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" + "sync" + "time" + models "vibeStonk/server/models/v1" + "vibeStonk/server/repository" +) + +var ( + ErrBadCredentials = errors.New("bad authentication credentials") + ErrExpiredCredentials = errors.New("expired credentials") + ErrExistingUser = errors.New("username taken") +) + +func NewAuthService(config *repository.Config) (AuthService, error) { + db, err := repository.GetSystemConnector(config) + if err != nil { + return nil, fmt.Errorf("failed to initialize DB connection for auth service: %w", err) + } + + users, err := repository.NewUserRepo(config, db) + if err != nil { + return nil, fmt.Errorf("failed to initialize the user repository while creating auth service: %w", err) + } + + sessions, err := repository.NewSessionRepo(config, db) + return &authService{ + db: db, + lock: &sync.Mutex{}, + sessions: sessions, + users: users, + }, nil +} + +type AuthService interface { + RegisterUser(username string, prefname string, password []byte) (*models.User, error) + // UpdateUser(user *models.User)(*models.User, error + AuthenticateUser(username string, password []byte) (*models.Session, error) + AuthenticateToken(tokenValue string) (*models.User, error) + Close() error +} + +type authService struct { + db *sql.DB + lock *sync.Mutex + sessions repository.SessionRepo + users repository.UserRepo +} + +func (a *authService) RegisterUser(username string, prefname string, password []byte) (*models.User, error) { + a.lock.Lock() + defer a.lock.Unlock() + + id := uuid.New().String() + // Generate password hash using bcrypt + hashedPassword, err := bcrypt.GenerateFromPassword(password, bcrypt.DefaultCost) + if err != nil { + return nil, fmt.Errorf("failed to hash password: %w", err) + } + + user := &models.User{ + Id: id, + UserName: username, + PrefName: prefname, + Hash: string(hashedPassword), + } + + eUser, err := a.users.GetByUsername(user.UserName) + if err == nil || eUser != nil { + return nil, ErrExistingUser + } + + user, err = a.users.Create(user) + if err != nil { + return nil, err + } + + return user, nil +} + +func (a *authService) AuthenticateUser(username string, password []byte) (*models.Session, error) { + user, err := a.users.GetByUsername(username) + if err != nil { + return nil, fmt.Errorf("failed to get user: %w", err) + } + + err = bcrypt.CompareHashAndPassword([]byte(user.Hash), password) + if err != nil { + return nil, ErrBadCredentials + } + + session, err := a.sessions.Create(user) + if err != nil { + return nil, fmt.Errorf("failed to create session for user: %w", err) + } + + return session, nil +} + +func (a *authService) AuthenticateToken(tokenValue string) (*models.User, error) { + session, err := a.sessions.Get(tokenValue) + if err != nil || session.Revoked { + return nil, ErrBadCredentials + } + + if time.Now().After(session.Expires.AsTime()) { + return nil, ErrExpiredCredentials + } + + user, err := a.users.Get(session.UserID) + if err != nil { + // this means that we couldn't find a valid user associated with the session + // this shouldn't really ever happen + return nil, ErrBadCredentials + } + + user.Hash = "" + + return user, nil +} + +func (a *authService) Close() error { + err := a.db.Close() + if err != nil { + return err + } + + return nil +} diff --git a/server/services/authService_test.go b/server/services/authService_test.go new file mode 100644 index 0000000..65671e8 --- /dev/null +++ b/server/services/authService_test.go @@ -0,0 +1,62 @@ +package services + +import ( + "testing" + "vibeStonk/server/repository" + "vibeStonk/server/util" +) + +func TestAuthService_RegisterUser(t *testing.T) { + configs := repository.GetTestConfigs() + testUserName, testUserPassword := util.GetTestUserCredentials() + for _, config := range configs { + service, err := NewAuthService(config) + if err != nil { + t.Errorf("error creating service: %v", err) + } + + user, err := service.RegisterUser(testUserName, "Test", testUserPassword) + if err != nil { + t.Errorf("expected no error, got: %+v", err) + } + + if user.UserName != testUserName || user.PrefName != "Test" { + t.Fail() + } + + user, err = service.RegisterUser(testUserName, "Test", testUserPassword) + if err == nil { + t.Error("expected error, got: nil") + } + if user != nil { + t.Errorf("expected nil user, got : %+v", user) + } + + } +} + +func TestAuthService_AuthenticateUser(t *testing.T) { + configs := repository.GetTestConfigs() + testUserName, testUserPassword := util.GetTestUserCredentials() + + for _, config := range configs { + service, err := NewAuthService(config) + if err != nil { + t.Errorf("expected nil error, got: %+v", err) + } + + result, err := service.AuthenticateUser(testUserName, testUserPassword) + if err != nil { + t.Errorf("expected valid login, got err: %+v", err) + } + + vUser, err := service.AuthenticateToken(result.Token) + if err != nil { + t.Errorf("expected to validate new session, got err: %+v", err) + } + + if vUser.UserName != testUserName { + t.Errorf("expected to get the test user, got: %+v", vUser) + } + } +} diff --git a/server/services/common.go b/server/services/common.go new file mode 100644 index 0000000..53fdf86 --- /dev/null +++ b/server/services/common.go @@ -0,0 +1,49 @@ +package services + +import ( + "fmt" + "vibeStonk/server/repository" +) + +type SystemServices struct { + AuthService AuthService + StockService StockService +} + +func (s *SystemServices) Close() []error { + services := []interface{ Close() error }{ + s.AuthService, + s.StockService, + } + errs := make([]error, 0, len(services)) + + for _, service := range services { + err := service.Close() + if err != nil { + errs = append(errs, err) + } + } + + if len(errs) > 0 { + return errs + } + + return nil +} + +func NewSystemServices(config *repository.Config) (*SystemServices, error) { + authService, err := NewAuthService(config) + if err != nil { + return nil, fmt.Errorf("failed to create system services: %w", err) + } + + stockService, err := NewStockService(config) + if err != nil { + return nil, fmt.Errorf("failed to create system stock service: %w", err) + } + + return &SystemServices{ + AuthService: authService, + StockService: stockService, + }, nil +} diff --git a/server/services/ledgerService.go b/server/services/ledgerService.go new file mode 100644 index 0000000..8fedf03 --- /dev/null +++ b/server/services/ledgerService.go @@ -0,0 +1,183 @@ +package services + +import ( + "errors" + "fmt" + "google.golang.org/protobuf/types/known/timestamppb" + "math" + "sync" + "time" + models "vibeStonk/server/models/v1" + "vibeStonk/server/repository" + "vibeStonk/server/util" +) + +var ( + ErrInsufficientHoldings = errors.New("insufficient stocks in holdings") +) + +func NewLedgerService(config *repository.Config, user *models.User) (LedgerService, error) { + connector, err := repository.GetUserConnector(config, user) + if err != nil { + return nil, fmt.Errorf("failed to get connector for user[%s]: %w", user.UserName, err) + } + + purchases, err := repository.NewPurchaseRepo(config, connector) + if err != nil { + return nil, fmt.Errorf("failed to initialize purcahses repo: %w", err) + } + + sales, err := repository.NewSaleRepo(config, connector) + if err != nil { + return nil, fmt.Errorf("failed to initialize sales repo: %w", err) + } + + transactions, err := repository.NewTransactionRepo(config, connector) + if err != nil { + return nil, fmt.Errorf("failed to initialize transactions repo: %w", err) + } + + holdings, err := repository.NewHoldingRepo(config, connector) + if err != nil { + return nil, fmt.Errorf("failed to initialize holdings repo: %w", err) + } + + salesFragments, err := repository.NewSaleFragmentRepo(config, connector) + if err != nil { + return nil, fmt.Errorf("failed to initialize sales fragment repo: %w", err) + } + + return &ledgerService{ + holdings: holdings, + lock: &sync.Mutex{}, + purchases: purchases, + sales: sales, + salesFragments: salesFragments, + transactions: transactions, + }, nil +} + +type LedgerService interface { + NewPurchase(stockID int64, purchaseDate time.Time, qty, price float64) (*models.Purchase, error) + NewSale(stockID int, saleDate time.Time, qty, price float64) (*models.Sale, error) + DeleteSale(sale *models.Sale) error + + // GetTransactionsByPurchase(purchaseID int) ([]models.Transaction, error) + // GetTransactionsBySale(saleID int) ([]models.Transaction, error) + +} + +type ledgerService struct { + holdings repository.HoldingRepo + lock *sync.Mutex + purchases repository.PurchaseRepo + sales repository.SaleRepo + salesFragments repository.SaleFragmentRepo + transactions repository.TransactionRepo +} + +func (l *ledgerService) DeleteSale(sale *models.Sale) error { + l.lock.Lock() + defer l.lock.Unlock() + + tx, err := l.sales.BeginDelete(sale) + if err != nil { + return fmt.Errorf("failed to begin delete transaction: %w", err) + } + + err = l.transactions.DeleteBySaleID(sale.Id) + if err != nil { + tx.Rollback() + return fmt.Errorf("failed to delete associated sales transactions: %w", err) + } + + // todo we need to rebuild the transactions table since the order of transactions matters. this might affect downstream + // transactions + return nil +} + +func (l *ledgerService) NewPurchase(stockID int64, purchaseDate time.Time, qty, price float64) (*models.Purchase, error) { + l.lock.Lock() + defer l.lock.Unlock() + + purchase := &models.Purchase{ + Id: -1, + PurchaseDate: timestamppb.New(purchaseDate), + StockID: stockID, + Qty: qty, + Price: price, + } + + purchase, err := l.purchases.Create(purchase) + if err != nil { + return nil, fmt.Errorf("failed to record purchase: %w", err) + } + + return purchase, nil +} + +func (l *ledgerService) NewSale(stockID int, saleDate time.Time, qty, price float64) (*models.Sale, error) { + l.lock.Lock() + defer l.lock.Unlock() + + holdings, err := l.holdings.GetUnsoldByStockID(int64(stockID)) + if err != nil { + return nil, fmt.Errorf("failed to get unsold holdings: %w", err) + } + + remaining := util.SumSlice(holdings, func(h *models.Holding) float64 { + return h.Qty - h.Sold + }) + + if remaining < qty { + return nil, ErrInsufficientHoldings + } + + toSell := qty + + sale, tx, err := l.sales.BeginSale(stockID, saleDate, qty, price) + if err != nil { + return nil, fmt.Errorf("failed to create sale: %w", err) + } + + for _, holding := range holdings { + txnAmount := math.Min(holding.Qty-holding.Sold, toSell) + txn := &models.Transaction{ + Id: -1, + PurchaseID: holding.Id, + SaleID: sale.Id, + Qty: txnAmount, + } + toSell -= txnAmount + txn, err = l.transactions.Create(txn) + if err != nil { + tx.Rollback() + return nil, fmt.Errorf("failed to record sub transaction: %w", err) + } + if toSell <= 0 { + break + } + } + + err = tx.Commit() + if err != nil { + tx.Rollback() + return nil, fmt.Errorf("failed to commit transactions: %w", err) + } + + return sale, nil +} + +// Holdings methods implementation + +func (l *ledgerService) GetHoldingsByStockID(stockID int64) ([]*models.Holding, error) { + return l.holdings.GetByStockID(stockID) +} + +func (l *ledgerService) GetUnsoldHoldingsByStockID(stockID int64) ([]*models.Holding, error) { + return l.holdings.GetUnsoldByStockID(stockID) +} + +func (l *ledgerService) GetSoldHoldingsByStockID(stockID int64) ([]*models.Holding, error) { + return l.holdings.GetSoldByStockID(stockID) +} diff --git a/server/services/ledgerService_test.go b/server/services/ledgerService_test.go new file mode 100644 index 0000000..b32c597 --- /dev/null +++ b/server/services/ledgerService_test.go @@ -0,0 +1,93 @@ +package services + +import ( + "errors" + "testing" + "time" + models "vibeStonk/server/models/v1" + "vibeStonk/server/repository" +) + +func TestLedgerService_NewSale(t *testing.T) { + configs := repository.GetTestConfigs() + for _, config := range configs { + system, err := NewTestSystem(t, config) + if err != nil { + t.Errorf("failed to initialize test system: %v", err) + } + + ledger, err := NewLedgerService(config, system.User) + if err != nil { + t.Errorf("error while initializing ledger: %v", err) + } + + purchase, err := ledger.NewPurchase(1, time.Now(), 3, 100) + if err != nil { + t.Errorf("failed to buy new stock: %v", err) + } + + if purchase.Id != 1 { + t.Errorf("expected first purchase ID to be 1, got %d", purchase.Id) + } + + purchase2, err := ledger.NewPurchase(1, time.Now(), 1, 200) + if err != nil { + t.Errorf("failed to buy new stock2: %v", err) + } + + if purchase2.Id != 2 { + t.Errorf("expected second purcahse ID to be 2, got %d", purchase2.Id) + } + + // expect failed sale + fSale, saleError := ledger.NewSale(1, time.Now(), 9, 250) + if saleError == nil { + t.Error("Expected error, got nil") + } else { + if !errors.Is(saleError, ErrInsufficientHoldings) { + t.Errorf("expected insufficient holdings, got: %+v", err) + } + } + if fSale != nil { + t.Errorf("expected nil failed sale, got: %+v", fSale) + } + + sale, err := ledger.NewSale(1, time.Now(), 0.5, 225) + if err != nil { + t.Errorf("expected nil err, got: %+v", err) + } + if sale.Id == -1 { + t.Errorf("expected valid sale id, got: %d", err) + } + + sale2, err := ledger.NewSale(1, time.Now(), 3, 250) + if err != nil { + t.Errorf("expected nil err, got: %+v", err) + } + if sale2.Id == -1 { + t.Errorf("expected valid sale id, got: %d", err) + } + } +} + +func TestLedgerService_DeleteSale(t *testing.T) { + configs := repository.GetTestConfigs() + for _, config := range configs { + system, err := NewTestSystem(t, config) + if err != nil { + t.Errorf("failed to initialize test system: %v", err) + } + + ledger, err := NewLedgerService(config, system.User) + if err != nil { + t.Errorf("error while initializing ledger: %v", err) + } + + sale := &models.Sale{Id: 2} + err = ledger.DeleteSale(sale) + if err != nil { + t.Errorf("failed to delete sale: %v", err) + } + + } +} diff --git a/server/services/stockService.go b/server/services/stockService.go new file mode 100644 index 0000000..5e02f14 --- /dev/null +++ b/server/services/stockService.go @@ -0,0 +1,115 @@ +package services + +import ( + "database/sql" + "errors" + "fmt" + "hash/fnv" + "sync" + models "vibeStonk/server/models/v1" + "vibeStonk/server/repository" +) + +var ( + ErrExistingStock = errors.New("stock with symbol exists") +) + +func NewStockService(config *repository.Config) (StockService, error) { + db, err := repository.GetStockConnector(config) + if err != nil { + return nil, fmt.Errorf("failed to initialize stock service: %w", err) + } + + stockRepo, err := repository.NewStockRepo(config, db) + if err != nil { + return nil, fmt.Errorf("failed to initialize stockRepo: %w", err) + } + + return &stockService{db: db, lock: &sync.Mutex{}, stockRepo: stockRepo}, nil +} + +type StockService interface { + NewStock(symbol, name string) (*models.Stock, error) + // Update(*models.Stock)(*models.Stock, error) + Get(id int) (*models.Stock, error) + GetBySymbol(symbol string) (*models.Stock, error) + GetAll() ([]*models.Stock, error) + GetByIDs(ids []int) ([]*models.Stock, error) + + Close() error +} + +type stockService struct { + db *sql.DB + lock *sync.Mutex + stockRepo repository.StockRepo +} + +func (s *stockService) Get(id int) (*models.Stock, error) { + return s.stockRepo.Get(int64(id)) +} + +func (s *stockService) GetBySymbol(symbol string) (*models.Stock, error) { + return s.stockRepo.GetBySymbol(symbol) +} + +func (s *stockService) NewStock(symbol, name string) (*models.Stock, error) { + s.lock.Lock() + defer s.lock.Unlock() + + stock := &models.Stock{ + Id: -1, + Symbol: symbol, + Name: name, + Color: createColor(symbol, name), + } + + stonk, err := s.stockRepo.GetBySymbol(stock.Symbol) + if err == nil || stonk != nil { + return nil, ErrExistingStock + } + + stock, err = s.stockRepo.Create(stock) + if err != nil { + return nil, fmt.Errorf("failed to create new stock: %w", err) + } + + return stock, nil +} + +func (s *stockService) GetAll() ([]*models.Stock, error) { + stocks, err := s.stockRepo.List() + if err != nil { + return nil, fmt.Errorf("failed to get all stocks: %w", err) + } + + return stocks, nil +} + +func (s *stockService) GetByIDs(ids []int) ([]*models.Stock, error) { + stocks, err := s.stockRepo.GetByIDs(ids) + if err != nil { + return nil, fmt.Errorf("failed to filter stocks by IDs: %w", err) + } + return stocks, nil +} + +func (s *stockService) Close() error { + return s.db.Close() +} + +// helpers +func createColor(symbol, name string) string { + // Generate a deterministic color based on symbol+name formatted as #RRGGBB hex + h := fnv.New32a() + h.Write([]byte(symbol + name)) + hash := h.Sum32() + + // Use the hash to generate RGB values + r := (hash & 0xFF0000) >> 16 + g := (hash & 0x00FF00) >> 8 + b := hash & 0x0000FF + + // Format as #RRGGBB hex + return fmt.Sprintf("#%02X%02X%02X", r, g, b) +} diff --git a/server/services/stockService_test.go b/server/services/stockService_test.go new file mode 100644 index 0000000..6f48cf6 --- /dev/null +++ b/server/services/stockService_test.go @@ -0,0 +1,91 @@ +package services + +import ( + "errors" + "testing" + "vibeStonk/server/repository" +) + +func TestCreateStock(t *testing.T) { + configs := repository.GetTestConfigs() + abc := "ABCDEFGHIJKLMNOP" + + for _, config := range configs { + service, err := NewStockService(config) + if err != nil { + t.Errorf("got error while initializing stock service: %v", err) + } + for i := 0; i < 10; i++ { + symbol := "A" + string(abc[i]) + name := "Test Stock " + string(abc[i]) + stock, err := service.NewStock(symbol, name) + if err != nil { + if errors.Is(err, ErrExistingStock) { + eStock, sErr := service.GetBySymbol(symbol) + if sErr != nil { + t.Errorf("the stock existed, but we couldn't fetch it: %+v", sErr) + } + stock = eStock + } else { + t.Errorf("error creating stock: %v", err) + } + } + + if stock == nil { + t.Error("newly created stock was nil") + } + + if stock.Symbol != symbol { + t.Error("stock symbols did not match") + } + if stock.Name != name { + t.Error("stock names did not match") + } + } + } +} + +func TestStockService_GetAll(t *testing.T) { + + configs := repository.GetTestConfigs() + + for _, config := range configs { + service, err := NewStockService(config) + if err != nil { + t.Errorf("got error while initializing stock service: %v", err) + } + + stocks, err := service.GetAll() + if err != nil { + t.Errorf("got error while getting all stocks: %v", err) + } + if len(stocks) != 10 { + t.Errorf("Expected 10 stocks, got %d", len(stocks)) + } + } +} + +func TestStockService_GetByIDs(t *testing.T) { + configs := repository.GetTestConfigs() + abc := "ABCDEFGHIJKLMNOP" + + for _, config := range configs { + service, err := NewStockService(config) + if err != nil { + t.Errorf("got error while initializing stock service: %v", err) + } + + ids := []int{4, 5, 6} + stocks, err := service.GetByIDs(ids) + if err != nil { + t.Errorf("got error while getting stocks by id: %v", err) + } + + for _, stock := range stocks { + symbol := "A" + string(abc[stock.Id-1]) + if stock.Symbol != symbol { + t.Errorf("stock symbols didn't match. expected %s got %s", symbol, stock.Symbol) + } + } + } +} diff --git a/server/services/testUtils.go b/server/services/testUtils.go new file mode 100644 index 0000000..c50d081 --- /dev/null +++ b/server/services/testUtils.go @@ -0,0 +1,49 @@ +package services + +import ( + "errors" + "fmt" + "testing" + models "vibeStonk/server/models/v1" + "vibeStonk/server/repository" + "vibeStonk/server/util" +) + +type TestSystem struct { + System *SystemServices + User *models.User +} + +func NewTestSystem(t *testing.T, config *repository.Config) (*TestSystem, error) { + t.Helper() + system, err := NewSystemServices(config) + if err != nil { + return nil, fmt.Errorf("failed to initialize test system: %w", err) + } + + uname, pword := util.GetTestUserCredentials() + user, err := system.AuthService.RegisterUser(uname, "Test", pword) + if err != nil { + if errors.Is(err, ErrExistingUser) { + session, sErr := system.AuthService.AuthenticateUser(uname, pword) + if sErr != nil { + return nil, fmt.Errorf("failed to authenticate test user: %w", err) + } + user, err = system.AuthService.AuthenticateToken(session.Token) + if err != nil { + return nil, fmt.Errorf("failed to validate the token we just made: %w", err) + } + } else { + return nil, fmt.Errorf("failed to register test user: %w", err) + } + } + + _, err = system.StockService.NewStock(util.TestStockSymbol, "Test Stock A") + if err != nil { + if !errors.Is(err, ErrExistingStock) { + return nil, fmt.Errorf("failed to create test stock: %w", err) + } + } + + return &TestSystem{System: system, User: user}, nil +} diff --git a/server/util/func.go b/server/util/func.go new file mode 100644 index 0000000..0a0bad7 --- /dev/null +++ b/server/util/func.go @@ -0,0 +1,13 @@ +package util + +type fNumber interface { + int8 | int16 | int32 | int64 | uint8 | uint16 | uint32 | uint64 | float32 | float64 +} + +func SumSlice[T any, K fNumber](objs []T, sumFn func(T) K) K { + sum := K(0) + for _, obj := range objs { + sum += sumFn(obj) + } + return sum +} diff --git a/server/util/func_test.go b/server/util/func_test.go new file mode 100644 index 0000000..4f5a8d4 --- /dev/null +++ b/server/util/func_test.go @@ -0,0 +1,37 @@ +package util + +import ( + "testing" +) + +func TestSumSlice(t *testing.T) { + // Test with integers + intSlice := []int64{1, 2, 3, 4, 5} + intSum := SumSlice(intSlice, func(i int64) int64 { return i }) + if intSum != 15 { + t.Errorf("Expected sum of integers to be 15, got %d", intSum) + } + + // Test with floats + floatSlice := []float64{1.1, 2.2, 3.3, 4.4, 5.5} + floatSum := SumSlice(floatSlice, func(f float64) float64 { return f }) + expectedFloatSum := 16.5 + if floatSum != expectedFloatSum { + t.Errorf("Expected sum of floats to be %f, got %f", expectedFloatSum, floatSum) + } + + // Test with custom struct + type Person struct { + Name string + Age int64 + } + people := []Person{ + {"Alice", 25}, + {"Bob", 30}, + {"Charlie", 35}, + } + ageSum := SumSlice(people, func(p Person) int64 { return p.Age }) + if ageSum != 90 { + t.Errorf("Expected sum of ages to be 90, got %d", ageSum) + } +} diff --git a/server/util/testDB.go b/server/util/testDB.go new file mode 100644 index 0000000..a17c832 --- /dev/null +++ b/server/util/testDB.go @@ -0,0 +1,14 @@ +package util + +const ( + testUserName = "test" + TestStockSymbol = "AA" +) + +var ( + testUserPassword = []byte("password5") +) + +func GetTestUserCredentials() (string, []byte) { + return testUserName, testUserPassword +}