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