Initial commit
This commit is contained in:
commit
a8a48a8436
56
.gitignore
vendored
Normal file
56
.gitignore
vendored
Normal file
@ -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/
|
||||||
214
.junie/guidelines.md
Normal file
214
.junie/guidelines.md
Normal file
@ -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
|
||||||
12
client/index.html
Normal file
12
client/index.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>VibeStonk</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3888
client/package-lock.json
generated
Normal file
3888
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
client/package.json
Normal file
35
client/package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
client/postcss.config.js
Normal file
6
client/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
69
client/src/App.tsx
Normal file
69
client/src/App.tsx
Normal file
@ -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<boolean | null>(null)
|
||||||
|
const [currentUser, setCurrentUser] = useState<User | null>(new User())
|
||||||
|
|
||||||
|
// Check if user is already authenticated on component mount
|
||||||
|
useEffect(() => {
|
||||||
|
|
||||||
|
const checkAuth: () => Promise<void> = async (): Promise<void> => {
|
||||||
|
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 (<div></div>);
|
||||||
|
case true:
|
||||||
|
return (<AuthenticatedLayout user={currentUser} setIsAuthenticated={setIsAuthenticated}/>);
|
||||||
|
case false:
|
||||||
|
return (<UnauthenticatedLayout onAuthSuccess={handleAuthSuccess}/>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeProvider>
|
||||||
|
<div className="app">
|
||||||
|
<ThemeToggle />
|
||||||
|
{pickDisplay(isAuthenticated)}
|
||||||
|
</div>
|
||||||
|
</ThemeProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
54
client/src/components/GreetingForm.tsx
Normal file
54
client/src/components/GreetingForm.tsx
Normal file
@ -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<GreetingFormProps> = ({user}) => {
|
||||||
|
const [name, setName] = useState<string>('');
|
||||||
|
const [label, setLabel] = useState<string>(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 (
|
||||||
|
<div>
|
||||||
|
Greetings, {label}
|
||||||
|
<br/>
|
||||||
|
You can alias for now using the field below.
|
||||||
|
<form className="greeting-form" onSubmit={handleSubmit}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Enter your name"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={isLoading || !name.trim()}>
|
||||||
|
{isLoading ? 'Loading...' : 'Get Greeting'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GreetingForm;
|
||||||
79
client/src/components/LoginForm.tsx
Normal file
79
client/src/components/LoginForm.tsx
Normal file
@ -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<LoginFormProps> = ({ 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 (
|
||||||
|
<div>
|
||||||
|
<h2>Login</h2>
|
||||||
|
{error && <div className="error-message">{error}</div>}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="auth-form">
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="userName">Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="userName"
|
||||||
|
value={userName}
|
||||||
|
onChange={(e) => setUserName(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="password">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" disabled={isLoading} className="submit-button">
|
||||||
|
{isLoading ? 'Logging in...' : 'Login'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoginForm;
|
||||||
30
client/src/components/NavAuthenticated.tsx
Normal file
30
client/src/components/NavAuthenticated.tsx
Normal file
@ -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<AuthNavParams> = ({setView, setIsAuthenticated}) => {
|
||||||
|
const handleLogout = () => {
|
||||||
|
authService.logout();
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<header className="header">
|
||||||
|
<h1>VibeStonk</h1>
|
||||||
|
<div>
|
||||||
|
<nav>
|
||||||
|
<button onClick={() => setView(AuthView.Stocks)}>Stocks</button>
|
||||||
|
<button onClick={() => setView(AuthView.Greeting)}>Greetings</button>
|
||||||
|
<button onClick={handleLogout}>Logout</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NavAuthenticated;
|
||||||
22
client/src/components/NavUnauthenticated.tsx
Normal file
22
client/src/components/NavUnauthenticated.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {UnauthView} from "../types/viewEnums.ts";
|
||||||
|
|
||||||
|
interface AuthNavParams {
|
||||||
|
setView(view: UnauthView): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const NavUnauthenticated: React.FC<AuthNavParams> = ({setView}) => {
|
||||||
|
return (
|
||||||
|
<header className="header">
|
||||||
|
<h1>VibeStonk</h1>
|
||||||
|
<div>
|
||||||
|
<nav>
|
||||||
|
<button onClick={() => setView(UnauthView.Login)}>Login</button>
|
||||||
|
<button onClick={() => setView(UnauthView.Register)}>Register</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NavUnauthenticated;
|
||||||
108
client/src/components/RegisterForm.tsx
Normal file
108
client/src/components/RegisterForm.tsx
Normal file
@ -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<RegisterFormProps> = ({ 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<void> => {
|
||||||
|
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 (
|
||||||
|
<div className="auth-form-container">
|
||||||
|
<h2>Register</h2>
|
||||||
|
{error && <div className="error-message">{error}</div>}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="auth-form">
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="userName">Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="userName"
|
||||||
|
value={userName}
|
||||||
|
onChange={(e) => setUserName(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="prefName">Preferred Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="prefName"
|
||||||
|
value={prefName}
|
||||||
|
onChange={(e) => setPrefName(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="password">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="passwordConfirm">Confirm Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="passwordConfirm"
|
||||||
|
value={passwordConfirm}
|
||||||
|
onChange={(e) => setPasswordConfirm(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" disabled={isLoading} className="submit-button">
|
||||||
|
{isLoading ? 'Registering...' : 'Register'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RegisterForm;
|
||||||
11
client/src/components/SiteFooter.tsx
Normal file
11
client/src/components/SiteFooter.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const SiteFooter: React.FC<any> = () =>{
|
||||||
|
return (
|
||||||
|
<footer className="footer">
|
||||||
|
<p>© {new Date().getFullYear()} Vibestonk</p>
|
||||||
|
</footer>
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SiteFooter;
|
||||||
28
client/src/components/ThemeToggle.tsx
Normal file
28
client/src/components/ThemeToggle.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useTheme } from '../context/ThemeContext';
|
||||||
|
|
||||||
|
const ThemeToggle: React.FC = () => {
|
||||||
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="theme-toggle"
|
||||||
|
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
||||||
|
>
|
||||||
|
{theme === 'light' ? (
|
||||||
|
// Moon icon for dark mode
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
// Sun icon for light mode
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fillRule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ThemeToggle;
|
||||||
12
client/src/composition/Stocks.tsx
Normal file
12
client/src/composition/Stocks.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
|
||||||
|
export interface StockViewProps{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const Stocks: React.FC<StockViewProps> = ({}: StockViewProps)=>{
|
||||||
|
return (
|
||||||
|
<div>Hi, I'm a stock view</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Stocks;
|
||||||
67
client/src/context/ThemeContext.tsx
Normal file
67
client/src/context/ThemeContext.tsx
Normal file
@ -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<ThemeContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
interface ThemeProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ 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<Theme>(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 (
|
||||||
|
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
};
|
||||||
37
client/src/layouts/AuthenticatedLayout.tsx
Normal file
37
client/src/layouts/AuthenticatedLayout.tsx
Normal file
@ -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<AuthenticationProps> = ({user, setIsAuthenticated}) => {
|
||||||
|
const [view, setView] = useState(AuthView.Stocks);
|
||||||
|
const showView = (view: AuthView) => {
|
||||||
|
switch (view) {
|
||||||
|
case AuthView.Stocks:
|
||||||
|
return (<Stocks/>)
|
||||||
|
case AuthView.Greeting:
|
||||||
|
return (<GreetingForm user={user}/>)
|
||||||
|
default:
|
||||||
|
throw new Error(`didn't understand view: ${view})`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="main-layout">
|
||||||
|
<NavAuthenticated setView={setView} setIsAuthenticated={setIsAuthenticated}/>
|
||||||
|
<main className="content">
|
||||||
|
{showView(view)}
|
||||||
|
</main>
|
||||||
|
<SiteFooter/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AuthenticatedLayout;
|
||||||
68
client/src/layouts/UnauthenticatedLayout.tsx
Normal file
68
client/src/layouts/UnauthenticatedLayout.tsx
Normal file
@ -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<AuthPageProps> = ({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 (
|
||||||
|
<LoginForm
|
||||||
|
onLoginSuccess={handleLoginSuccess}
|
||||||
|
swap={() => setView(UnauthView.Register)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case UnauthView.Register:
|
||||||
|
return (
|
||||||
|
<RegisterForm
|
||||||
|
onRegisterSuccess={handleRegisterSuccess}
|
||||||
|
swap={() => setView(UnauthView.Login)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
throw new Error("main page done goofed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="main-layout">
|
||||||
|
<NavUnauthenticated setView={setView} />
|
||||||
|
<header className="header">
|
||||||
|
<h1>YOU ARE NOT KNOWN</h1>
|
||||||
|
</header>
|
||||||
|
<main className="content">
|
||||||
|
<div className="auth-page">
|
||||||
|
<div className="auth-container">
|
||||||
|
{showView(view)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<SiteFooter/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UnauthenticatedLayout;
|
||||||
14
client/src/main.tsx
Normal file
14
client/src/main.tsx
Normal file
@ -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(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
47
client/src/services/authService.ts
Normal file
47
client/src/services/authService.ts
Normal file
@ -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')
|
||||||
|
};
|
||||||
|
|
||||||
7
client/src/services/healthService.ts
Normal file
7
client/src/services/healthService.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import client from './stonkService.ts';
|
||||||
|
import {AxiosResponse} from "axios";
|
||||||
|
// Health endpoints
|
||||||
|
export const healthService = {
|
||||||
|
getStatus: (): Promise<AxiosResponse<string, any>> => client.get('/health'),
|
||||||
|
getGreeting: (name: string) => client.post(`/health?name=${encodeURIComponent(name)}`),
|
||||||
|
};
|
||||||
20
client/src/services/stonkService.ts
Normal file
20
client/src/services/stonkService.ts
Normal file
@ -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;
|
||||||
56
client/src/styles/App.css
Normal file
56
client/src/styles/App.css
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
47
client/src/styles/index.css
Normal file
47
client/src/styles/index.css
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
client/src/types/index.ts
Normal file
8
client/src/types/index.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
10
client/src/types/viewEnums.ts
Normal file
10
client/src/types/viewEnums.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
|
||||||
|
export enum AuthView {
|
||||||
|
Stocks = 0,
|
||||||
|
Greeting = 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum UnauthView {
|
||||||
|
Login = 0,
|
||||||
|
Register = 1,
|
||||||
|
}
|
||||||
35
client/tailwind.config.js
Normal file
35
client/tailwind.config.js
Normal file
@ -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: [],
|
||||||
|
}
|
||||||
25
client/tsconfig.json
Normal file
25
client/tsconfig.json
Normal file
@ -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" }]
|
||||||
|
}
|
||||||
10
client/tsconfig.node.json
Normal file
10
client/tsconfig.node.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
16
client/vite.config.ts
Normal file
16
client/vite.config.ts
Normal file
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
20
docker/Dockerfile.api
Normal file
20
docker/Dockerfile.api
Normal file
@ -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"]
|
||||||
7
docker/Dockerfile.static
Normal file
7
docker/Dockerfile.static
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
COPY dist/content/ /usr/share/nginx/html/
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
31
go.mod
Normal file
31
go.mod
Normal file
@ -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
|
||||||
|
)
|
||||||
75
go.sum
Normal file
75
go.sum
Normal file
@ -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=
|
||||||
6
kubes/create-registry-secret.sh
Executable file
6
kubes/create-registry-secret.sh
Executable file
@ -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}
|
||||||
117
kubes/deployment.yaml
Normal file
117
kubes/deployment.yaml
Normal file
@ -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
|
||||||
|
---
|
||||||
4
kubes/namespace.yaml
Normal file
4
kubes/namespace.yaml
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: vibe-stonk
|
||||||
16
proto/v1/holding.proto
Normal file
16
proto/v1/holding.proto
Normal file
@ -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;
|
||||||
|
}
|
||||||
10
proto/v1/login.proto
Normal file
10
proto/v1/login.proto
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package api.v1;
|
||||||
|
|
||||||
|
option go_package = "models/v1";
|
||||||
|
|
||||||
|
message Login{
|
||||||
|
string userName = 1;
|
||||||
|
bytes password = 2;
|
||||||
|
}
|
||||||
15
proto/v1/purchase.proto
Normal file
15
proto/v1/purchase.proto
Normal file
@ -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;
|
||||||
|
}
|
||||||
16
proto/v1/sale-fragment.proto
Normal file
16
proto/v1/sale-fragment.proto
Normal file
@ -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;
|
||||||
|
}
|
||||||
15
proto/v1/sale.proto
Normal file
15
proto/v1/sale.proto
Normal file
@ -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;
|
||||||
|
}
|
||||||
9
proto/v1/server-health.proto
Normal file
9
proto/v1/server-health.proto
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package api.v1;
|
||||||
|
|
||||||
|
option go_package = "models/v1";
|
||||||
|
|
||||||
|
message ServerStatus{
|
||||||
|
string status = 1;
|
||||||
|
}
|
||||||
15
proto/v1/session.proto
Normal file
15
proto/v1/session.proto
Normal file
@ -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;
|
||||||
|
}
|
||||||
12
proto/v1/stock.proto
Normal file
12
proto/v1/stock.proto
Normal file
@ -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;
|
||||||
|
}
|
||||||
12
proto/v1/transaction.proto
Normal file
12
proto/v1/transaction.proto
Normal file
@ -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;
|
||||||
|
}
|
||||||
14
proto/v1/user-registration.proto
Normal file
14
proto/v1/user-registration.proto
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
18
proto/v1/user.proto
Normal file
18
proto/v1/user.proto
Normal file
@ -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;
|
||||||
|
}
|
||||||
22
scripts/build.sh
Executable file
22
scripts/build.sh
Executable file
@ -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
|
||||||
46
scripts/build_docker.sh
Executable file
46
scripts/build_docker.sh
Executable file
@ -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"
|
||||||
36
scripts/build_docker_local.sh
Executable file
36
scripts/build_docker_local.sh
Executable file
@ -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}"
|
||||||
7
scripts/deploy.sh
Normal file
7
scripts/deploy.sh
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
cd $GOPATH/src/vibeStonk/ || exit
|
||||||
|
|
||||||
|
./scripts/build_docker.sh || exit
|
||||||
|
|
||||||
|
kubectl rollout restart deployment -n vibe-stonk
|
||||||
35
scripts/generate_proto.sh
Executable file
35
scripts/generate_proto.sh
Executable file
@ -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!"
|
||||||
8
scripts/remove_test_dbs.sh
Normal file
8
scripts/remove_test_dbs.sh
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -xe
|
||||||
|
|
||||||
|
cd $GOPATH/src/vibeStonk || exit
|
||||||
|
|
||||||
|
rm -rf dist/test-data
|
||||||
|
rm -rf server/services/test-data
|
||||||
4
server/cmd/scratch.go
Normal file
4
server/cmd/scratch.go
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
}
|
||||||
40
server/cmd/start.go
Normal file
40
server/cmd/start.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
59
server/repository/config.go
Normal file
59
server/repository/config.go
Normal file
@ -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
|
||||||
|
)
|
||||||
39
server/repository/defDB.go
Normal file
39
server/repository/defDB.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
43
server/repository/defHolding.go
Normal file
43
server/repository/defHolding.go
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
29
server/repository/defPurchase.go
Normal file
29
server/repository/defPurchase.go
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
28
server/repository/defSale.go
Normal file
28
server/repository/defSale.go
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
20
server/repository/defSaleFragment.go
Normal file
20
server/repository/defSaleFragment.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
22
server/repository/defSession.go
Normal file
22
server/repository/defSession.go
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
30
server/repository/defStock.go
Normal file
30
server/repository/defStock.go
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
26
server/repository/defTransaction.go
Normal file
26
server/repository/defTransaction.go
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
37
server/repository/defUsers.go
Normal file
37
server/repository/defUsers.go
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
46
server/repository/sqliteDB.go
Normal file
46
server/repository/sqliteDB.go
Normal file
@ -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
|
||||||
|
}
|
||||||
333
server/repository/sqliteHolding.go
Normal file
333
server/repository/sqliteHolding.go
Normal file
@ -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
|
||||||
|
}
|
||||||
226
server/repository/sqlitePurchase.go
Normal file
226
server/repository/sqlitePurchase.go
Normal file
@ -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
|
||||||
|
}
|
||||||
206
server/repository/sqliteSale.go
Normal file
206
server/repository/sqliteSale.go
Normal file
@ -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
|
||||||
|
}
|
||||||
88
server/repository/sqliteSaleFragment.go
Normal file
88
server/repository/sqliteSaleFragment.go
Normal file
@ -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
|
||||||
|
}
|
||||||
165
server/repository/sqliteSession.go
Normal file
165
server/repository/sqliteSession.go
Normal file
@ -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
|
||||||
|
}
|
||||||
228
server/repository/sqliteStock.go
Normal file
228
server/repository/sqliteStock.go
Normal file
@ -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()
|
||||||
|
}
|
||||||
200
server/repository/sqliteTransaction.go
Normal file
200
server/repository/sqliteTransaction.go
Normal file
@ -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
|
||||||
|
}
|
||||||
152
server/repository/sqliteUsers.go
Normal file
152
server/repository/sqliteUsers.go
Normal file
@ -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
|
||||||
|
}
|
||||||
67
server/routes/route.go
Normal file
67
server/routes/route.go
Normal file
@ -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
|
||||||
50
server/routes/routeHealth.go
Normal file
50
server/routes/routeHealth.go
Normal file
@ -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
|
||||||
|
}
|
||||||
268
server/routes/routeUser.go
Normal file
268
server/routes/routeUser.go
Normal file
@ -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
|
||||||
155
server/routes/server.go
Normal file
155
server/routes/server.go
Normal file
@ -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
|
||||||
135
server/services/authService.go
Normal file
135
server/services/authService.go
Normal file
@ -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
|
||||||
|
}
|
||||||
62
server/services/authService_test.go
Normal file
62
server/services/authService_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
server/services/common.go
Normal file
49
server/services/common.go
Normal file
@ -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
|
||||||
|
}
|
||||||
183
server/services/ledgerService.go
Normal file
183
server/services/ledgerService.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
93
server/services/ledgerService_test.go
Normal file
93
server/services/ledgerService_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
115
server/services/stockService.go
Normal file
115
server/services/stockService.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
91
server/services/stockService_test.go
Normal file
91
server/services/stockService_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
server/services/testUtils.go
Normal file
49
server/services/testUtils.go
Normal file
@ -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
|
||||||
|
}
|
||||||
13
server/util/func.go
Normal file
13
server/util/func.go
Normal file
@ -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
|
||||||
|
}
|
||||||
37
server/util/func_test.go
Normal file
37
server/util/func_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
14
server/util/testDB.go
Normal file
14
server/util/testDB.go
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
const (
|
||||||
|
testUserName = "test"
|
||||||
|
TestStockSymbol = "AA"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
testUserPassword = []byte("password5")
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetTestUserCredentials() (string, []byte) {
|
||||||
|
return testUserName, testUserPassword
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user