Initial commit

This commit is contained in:
dtookey 2025-06-12 16:57:42 -04:00
commit a8a48a8436
90 changed files with 9059 additions and 0 deletions

56
.gitignore vendored Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

35
client/package.json Normal file
View 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
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

69
client/src/App.tsx Normal file
View 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

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@ -0,0 +1,11 @@
import React from "react";
const SiteFooter: React.FC<any> = () =>{
return (
<footer className="footer">
<p>&copy; {new Date().getFullYear()} Vibestonk</p>
</footer>
)
};
export default SiteFooter;

View 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;

View 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;

View 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;
};

View 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;

View 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
View 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>,
)

View 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')
};

View 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)}`),
};

View 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
View 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;
}
}

View 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;
}
}

View 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;
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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=

View 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
View 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
View File

@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: vibe-stonk

16
proto/v1/holding.proto Normal file
View 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
View 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
View 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;
}

View 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
View 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;
}

View 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
View 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
View 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;
}

View 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;
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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!"

View 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
View File

@ -0,0 +1,4 @@
package main
func main() {
}

40
server/cmd/start.go Normal file
View 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)
}

View 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
)

View 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)
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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)
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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()
}

View 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
}

View 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
View 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

View 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
View 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
View 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

View 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
}

View 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
View 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
}

View 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)
}

View 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)
}
}
}

View 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)
}

View 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)
}
}
}
}

View 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
View 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
View 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
View File

@ -0,0 +1,14 @@
package util
const (
testUserName = "test"
TestStockSymbol = "AA"
)
var (
testUserPassword = []byte("password5")
)
func GetTestUserCredentials() (string, []byte) {
return testUserName, testUserPassword
}