diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..9de7b29
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,15 @@
+# Database Configuration
+DB_HOST=localhost
+DB_PORT=5432
+DB_USER=postgres
+DB_PASSWORD=your_password_here
+DB_NAME=geeklife
+DB_SSLMODE=disable
+
+# Authentication Configuration
+JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
+SESSION_DURATION=24
+
+# Application Configuration
+ENVIRONMENT=development
+LOG_LEVEL=info
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..7d3af24
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,33 @@
+# Dockerfile for Geek-Life v2.0 Multi-Tenant
+FROM golang:1.19-alpine AS builder
+
+WORKDIR /app
+
+# Install dependencies
+COPY go.mod go.sum ./
+RUN go mod download
+
+# Copy source code
+COPY . .
+
+# Build the application
+RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o geek-life app/*.go
+
+# Final stage
+FROM alpine:latest
+
+RUN apk --no-cache add ca-certificates tzdata
+WORKDIR /root/
+
+# Copy the binary
+COPY --from=builder /app/geek-life .
+COPY --from=builder /app/migrations ./migrations/
+COPY --from=builder /app/.env.example .
+
+# Create non-root user
+RUN adduser -D -s /bin/sh geeklife
+USER geeklife
+
+EXPOSE 8080
+
+CMD ["./geek-life"]
\ No newline at end of file
diff --git a/README_v2.md b/README_v2.md
new file mode 100644
index 0000000..b4239be
--- /dev/null
+++ b/README_v2.md
@@ -0,0 +1,204 @@
+# Geek-Life v2.0 - Multi-Tenant CLI Task Manager
+
+
+
+
+
+## 🚀 What's New in v2.0
+
+- **Multi-Tenant Support**: Multiple organizations can use the same instance
+- **User Management**: Multiple users per tenant with secure authentication
+- **PostgreSQL Backend**: Centralized database with better performance and reliability
+- **Session Management**: Secure login/logout with session tokens
+- **Database Migrations**: Automated schema management
+- **Row-Level Security**: Data isolation between tenants
+
+## 🏗️ Architecture
+
+### Multi-Tenant Design
+- **Tenants**: Organizations or teams
+- **Users**: Individual users within a tenant
+- **Projects & Tasks**: Scoped to specific users within tenants
+- **Sessions**: Secure authentication tokens
+
+### Database Schema
+- PostgreSQL with Row-Level Security (RLS)
+- Automated migrations
+- Optimized indexes for performance
+- UUID support for external integrations
+
+## 🛠️ Installation & Setup
+
+### Prerequisites
+- Go 1.19 or later
+- PostgreSQL 12 or later
+
+### Database Setup
+1. Create a PostgreSQL database:
+```sql
+CREATE DATABASE geeklife;
+```
+
+2. Copy the environment configuration:
+```bash
+cp .env.example .env
+```
+
+3. Update `.env` with your database credentials:
+```env
+DB_HOST=localhost
+DB_PORT=5432
+DB_USER=postgres
+DB_PASSWORD=your_password
+DB_NAME=geeklife
+DB_SSLMODE=disable
+```
+
+### Build & Run
+```bash
+# Install dependencies
+go mod tidy
+
+# Run database migrations
+go run app/*.go --migrate
+
+# Start the application
+go run app/*.go
+```
+
+## 🔐 Authentication
+
+### First Time Setup
+1. **Register Tenant**: Create a new organization/tenant with an admin user
+2. **Register User**: Add additional users to an existing tenant
+3. **Login**: Access with existing credentials
+
+### Session Management
+- Sessions are automatically saved locally
+- Use `Ctrl+L` to logout
+- Sessions expire after 24 hours (configurable)
+
+## 🎮 Usage
+
+### Keyboard Shortcuts
+- `Tab` / `Shift+Tab`: Navigate between panes
+- `Enter`: Select/Edit item
+- `Ctrl+C`: Quit application
+- `Ctrl+L`: Logout
+- `Esc`: Cancel/Go back
+
+### Multi-User Workflow
+1. **Tenant Admin**: Registers the tenant and becomes the first user
+2. **Team Members**: Register as users within the existing tenant
+3. **Data Isolation**: Each user sees only their own projects and tasks
+4. **Collaboration**: Future versions will support shared projects
+
+## 🔧 Configuration
+
+### Environment Variables
+```env
+# Database Configuration
+DB_HOST=localhost
+DB_PORT=5432
+DB_USER=postgres
+DB_PASSWORD=your_password
+DB_NAME=geeklife
+DB_SSLMODE=disable
+
+# Authentication Configuration
+JWT_SECRET=your-super-secret-jwt-key
+SESSION_DURATION=24
+
+# Application Configuration
+ENVIRONMENT=development
+LOG_LEVEL=info
+```
+
+### Command Line Options
+```bash
+# Run database migrations
+./geek-life --migrate
+
+# Help
+./geek-life --help
+```
+
+## 🗄️ Database Migrations
+
+The application includes an automated migration system:
+
+```bash
+# Run all pending migrations
+go run app/*.go --migrate
+```
+
+Migrations are located in the `migrations/` directory and are automatically applied in order.
+
+## 🔒 Security Features
+
+- **Password Hashing**: bcrypt with salt
+- **Session Tokens**: Cryptographically secure random tokens
+- **Row-Level Security**: Database-level tenant isolation
+- **SQL Injection Protection**: Parameterized queries
+- **Session Expiration**: Configurable session timeouts
+
+## 🏢 Multi-Tenant Benefits
+
+- **Cost Effective**: Single instance serves multiple organizations
+- **Data Isolation**: Complete separation between tenants
+- **Scalable**: Supports unlimited tenants and users
+- **Secure**: Row-level security ensures data privacy
+- **Maintainable**: Single codebase for all tenants
+
+## 🔄 Migration from v1.0
+
+The new version maintains backward compatibility with the Storm database format. You can:
+
+1. Continue using Storm (single-user mode)
+2. Migrate to PostgreSQL for multi-tenant features
+3. Run both versions side by side
+
+## 🚧 Development
+
+### Project Structure
+```
+├── app/ # Main application and UI components
+├── auth/ # Authentication service
+├── config/ # Configuration management
+├── migration/ # Database migration system
+├── model/ # Data models
+├── repository/ # Data access layer
+│ ├── postgres/ # PostgreSQL implementations
+│ └── storm/ # Storm implementations (legacy)
+├── util/ # Utility functions
+├── migrations/ # SQL migration files
+└── docs/ # Documentation
+```
+
+### Adding New Features
+1. Update models if needed
+2. Add repository methods
+3. Update UI components
+4. Create migrations for schema changes
+
+## 📝 License
+
+MIT License - see LICENSE file for details.
+
+## 🤝 Contributing
+
+1. Fork the repository
+2. Create a feature branch
+3. Make your changes
+4. Add tests if applicable
+5. Submit a pull request
+
+## 📞 Support
+
+- Create an issue for bug reports
+- Use discussions for feature requests
+- Check the wiki for detailed documentation
+
+---
+
+**Geek-Life v2.0** - Now with multi-tenant support for teams and organizations! 🚀
\ No newline at end of file
diff --git a/app/main.go b/app/main.go
new file mode 100644
index 0000000..170d1cc
--- /dev/null
+++ b/app/main.go
@@ -0,0 +1,292 @@
+package main
+
+import (
+ "fmt"
+ "log"
+ "os"
+ "path/filepath"
+
+ "github.com/gdamore/tcell/v2"
+ "github.com/jmoiron/sqlx"
+ "github.com/rivo/tview"
+ flag "github.com/spf13/pflag"
+ _ "github.com/lib/pq"
+
+ "github.com/ajaxray/geek-life/auth"
+ "github.com/ajaxray/geek-life/config"
+ "github.com/ajaxray/geek-life/migration"
+ "github.com/ajaxray/geek-life/model"
+ "github.com/ajaxray/geek-life/repository"
+ "github.com/ajaxray/geek-life/repository/postgres"
+ "github.com/ajaxray/geek-life/util"
+)
+
+var (
+ app *tview.Application
+ layout, contents *tview.Flex
+
+ statusBar *StatusBar
+ projectPane *ProjectPane
+ taskPane *TaskPane
+ taskDetailPane *TaskDetailPane
+ projectDetailPane *ProjectDetailPane
+
+ db *sqlx.DB
+ projectRepo repository.ProjectRepository
+ taskRepo repository.TaskRepository
+ authService *auth.AuthService
+ userContext *model.UserContext
+
+ // Configuration
+ cfg *config.Config
+
+ // Flag variables
+ migrate bool
+)
+
+func init() {
+ flag.BoolVarP(&migrate, "migrate", "m", false, "Run database migrations")
+}
+
+func main() {
+ flag.Parse()
+
+ // Load configuration
+ var err error
+ cfg, err = config.Load()
+ if err != nil {
+ log.Fatalf("Failed to load configuration: %v", err)
+ }
+
+ // Connect to PostgreSQL
+ db, err = sqlx.Connect("postgres", cfg.GetDSN())
+ if err != nil {
+ log.Fatalf("Failed to connect to database: %v", err)
+ }
+ defer db.Close()
+
+ // Run migrations if requested
+ if migrate {
+ migrator := migration.NewMigrator(db, "migrations")
+ if err := migrator.Run(); err != nil {
+ log.Fatalf("Failed to run migrations: %v", err)
+ }
+ fmt.Println("Migrations completed successfully")
+ return
+ }
+
+ // Initialize repositories
+ userRepo := postgres.NewUserRepository(db)
+ tenantRepo := postgres.NewTenantRepository(db)
+ sessionRepo := postgres.NewSessionRepository(db)
+ projectRepo = postgres.NewProjectRepository(db)
+ taskRepo = postgres.NewTaskRepository(db)
+
+ // Initialize auth service
+ authService = auth.NewAuthService(userRepo, tenantRepo, sessionRepo, cfg.Auth.SessionDuration)
+
+ // Check for existing session
+ sessionToken := loadSessionToken()
+ if sessionToken != "" {
+ userContext, err = authService.ValidateSession(sessionToken)
+ if err != nil {
+ // Invalid session, remove it
+ removeSessionToken()
+ sessionToken = ""
+ }
+ }
+
+ // If no valid session, show login screen
+ if userContext == nil {
+ showAuthScreen()
+ return
+ }
+
+ // Initialize and start the main application
+ startMainApp()
+}
+
+func showAuthScreen() {
+ app = tview.NewApplication()
+
+ // Create auth form
+ form := tview.NewForm()
+ form.SetBorder(true).SetTitle("Geek-Life Authentication")
+
+ var tenantName, username, password string
+ var isLogin bool = true
+
+ form.AddInputField("Tenant", "", 20, nil, func(text string) {
+ tenantName = text
+ })
+ form.AddInputField("Username/Email", "", 20, nil, func(text string) {
+ username = text
+ })
+ form.AddPasswordField("Password", "", 20, '*', func(text string) {
+ password = text
+ })
+
+ form.AddButton("Login", func() {
+ if tenantName == "" || username == "" || password == "" {
+ showError("All fields are required")
+ return
+ }
+
+ ctx, token, err := authService.Login(tenantName, username, password)
+ if err != nil {
+ showError(fmt.Sprintf("Login failed: %v", err))
+ return
+ }
+
+ userContext = ctx
+ saveSessionToken(token)
+ app.Stop()
+ startMainApp()
+ })
+
+ form.AddButton("Register User", func() {
+ if tenantName == "" || username == "" || password == "" {
+ showError("All fields are required")
+ return
+ }
+
+ ctx, token, err := authService.RegisterUser(tenantName, username, username, password)
+ if err != nil {
+ showError(fmt.Sprintf("Registration failed: %v", err))
+ return
+ }
+
+ userContext = ctx
+ saveSessionToken(token)
+ app.Stop()
+ startMainApp()
+ })
+
+ form.AddButton("Register Tenant", func() {
+ if tenantName == "" || username == "" || password == "" {
+ showError("All fields are required")
+ return
+ }
+
+ ctx, token, err := authService.RegisterTenant(tenantName, username, username, password)
+ if err != nil {
+ showError(fmt.Sprintf("Tenant registration failed: %v", err))
+ return
+ }
+
+ userContext = ctx
+ saveSessionToken(token)
+ app.Stop()
+ startMainApp()
+ })
+
+ form.AddButton("Quit", func() {
+ app.Stop()
+ })
+
+ // Create info text
+ info := tview.NewTextView()
+ info.SetText("Welcome to Geek-Life!\n\n" +
+ "• Login: Use existing tenant and user credentials\n" +
+ "• Register User: Create a new user in an existing tenant\n" +
+ "• Register Tenant: Create a new tenant with admin user\n\n" +
+ "Use Tab to navigate between fields")
+ info.SetBorder(true).SetTitle("Instructions")
+
+ // Layout
+ flex := tview.NewFlex().SetDirection(tview.FlexRow)
+ flex.AddItem(info, 8, 0, false)
+ flex.AddItem(form, 0, 1, true)
+
+ if err := app.SetRoot(flex, true).EnableMouse(true).Run(); err != nil {
+ panic(err)
+ }
+}
+
+func startMainApp() {
+ app = tview.NewApplication()
+
+ statusBar = NewStatusBar(fmt.Sprintf("User: %s | Tenant: %s", userContext.User.Username, userContext.Tenant.Name))
+ projectPane = NewProjectPane(userContext)
+ taskPane = NewTaskPane(userContext)
+ taskDetailPane = NewTaskDetailPane(userContext)
+ projectDetailPane = NewProjectDetailPane(userContext)
+
+ layout = tview.NewFlex().SetDirection(tview.FlexRow).
+ AddItem(tview.NewFlex().SetDirection(tview.FlexColumn).
+ AddItem(projectPane.GetView(), 25, 1, true).
+ AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
+ AddItem(taskPane.GetView(), 0, 7, false).
+ AddItem(taskDetailPane.GetView(), 0, 3, false), 0, 5, false).
+ AddItem(projectDetailPane.GetView(), 0, 2, false), 0, 1, true).
+ AddItem(statusBar.GetView(), 1, 1, false)
+
+ app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
+ if event.Key() == tcell.KeyCtrlC {
+ app.Stop()
+ return nil
+ }
+ if event.Key() == tcell.KeyCtrlL {
+ // Logout
+ sessionToken := loadSessionToken()
+ if sessionToken != "" {
+ authService.Logout(sessionToken)
+ removeSessionToken()
+ }
+ app.Stop()
+ userContext = nil
+ showAuthScreen()
+ return nil
+ }
+ return event
+ })
+
+ if err := app.SetRoot(layout, true).EnableMouse(true).Run(); err != nil {
+ panic(err)
+ }
+}
+
+func showError(message string) {
+ modal := tview.NewModal().
+ SetText(message).
+ AddButtons([]string{"OK"}).
+ SetDoneFunc(func(buttonIndex int, buttonLabel string) {
+ app.SetRoot(app.GetRoot(), true)
+ })
+ app.SetRoot(modal, false)
+}
+
+func saveSessionToken(token string) {
+ homeDir, err := os.UserHomeDir()
+ if err != nil {
+ return
+ }
+
+ sessionFile := filepath.Join(homeDir, ".geek-life-session")
+ os.WriteFile(sessionFile, []byte(token), 0600)
+}
+
+func loadSessionToken() string {
+ homeDir, err := os.UserHomeDir()
+ if err != nil {
+ return ""
+ }
+
+ sessionFile := filepath.Join(homeDir, ".geek-life-session")
+ data, err := os.ReadFile(sessionFile)
+ if err != nil {
+ return ""
+ }
+
+ return string(data)
+}
+
+func removeSessionToken() {
+ homeDir, err := os.UserHomeDir()
+ if err != nil {
+ return
+ }
+
+ sessionFile := filepath.Join(homeDir, ".geek-life-session")
+ os.Remove(sessionFile)
+}
\ No newline at end of file
diff --git a/auth/service.go b/auth/service.go
new file mode 100644
index 0000000..ae01ef7
--- /dev/null
+++ b/auth/service.go
@@ -0,0 +1,229 @@
+package auth
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "time"
+
+ "golang.org/x/crypto/bcrypt"
+ "github.com/ajaxray/geek-life/model"
+ "github.com/ajaxray/geek-life/repository"
+)
+
+var (
+ ErrInvalidCredentials = errors.New("invalid credentials")
+ ErrUserExists = errors.New("user already exists")
+ ErrTenantExists = errors.New("tenant already exists")
+)
+
+// AuthService handles authentication operations
+type AuthService struct {
+ userRepo repository.UserRepository
+ tenantRepo repository.TenantRepository
+ sessionRepo repository.SessionRepository
+ sessionDuration int // in hours
+}
+
+// NewAuthService creates a new authentication service
+func NewAuthService(userRepo repository.UserRepository, tenantRepo repository.TenantRepository, sessionRepo repository.SessionRepository, sessionDuration int) *AuthService {
+ return &AuthService{
+ userRepo: userRepo,
+ tenantRepo: tenantRepo,
+ sessionRepo: sessionRepo,
+ sessionDuration: sessionDuration,
+ }
+}
+
+// RegisterTenant creates a new tenant with an admin user
+func (s *AuthService) RegisterTenant(tenantName, username, email, password string) (*model.UserContext, string, error) {
+ // Check if tenant already exists
+ if _, err := s.tenantRepo.GetByName(tenantName); err == nil {
+ return nil, "", ErrTenantExists
+ }
+
+ // Create tenant
+ tenant, err := s.tenantRepo.Create(tenantName)
+ if err != nil {
+ return nil, "", err
+ }
+
+ // Check if user already exists in this tenant
+ if _, err := s.userRepo.GetByUsername(tenant.ID, username); err == nil {
+ return nil, "", ErrUserExists
+ }
+ if _, err := s.userRepo.GetByEmail(tenant.ID, email); err == nil {
+ return nil, "", ErrUserExists
+ }
+
+ // Hash password
+ hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+ if err != nil {
+ return nil, "", err
+ }
+
+ // Create user
+ user, err := s.userRepo.Create(tenant.ID, username, email, string(hashedPassword))
+ if err != nil {
+ return nil, "", err
+ }
+
+ // Create session
+ token, err := s.generateToken()
+ if err != nil {
+ return nil, "", err
+ }
+
+ expiresAt := time.Now().Add(time.Duration(s.sessionDuration) * time.Hour).Unix()
+ _, err = s.sessionRepo.Create(user.ID, token, expiresAt)
+ if err != nil {
+ return nil, "", err
+ }
+
+ ctx := &model.UserContext{
+ User: user,
+ Tenant: tenant,
+ }
+
+ return ctx, token, nil
+}
+
+// RegisterUser creates a new user in an existing tenant
+func (s *AuthService) RegisterUser(tenantName, username, email, password string) (*model.UserContext, string, error) {
+ // Get tenant
+ tenant, err := s.tenantRepo.GetByName(tenantName)
+ if err != nil {
+ return nil, "", err
+ }
+
+ // Check if user already exists in this tenant
+ if _, err := s.userRepo.GetByUsername(tenant.ID, username); err == nil {
+ return nil, "", ErrUserExists
+ }
+ if _, err := s.userRepo.GetByEmail(tenant.ID, email); err == nil {
+ return nil, "", ErrUserExists
+ }
+
+ // Hash password
+ hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+ if err != nil {
+ return nil, "", err
+ }
+
+ // Create user
+ user, err := s.userRepo.Create(tenant.ID, username, email, string(hashedPassword))
+ if err != nil {
+ return nil, "", err
+ }
+
+ // Create session
+ token, err := s.generateToken()
+ if err != nil {
+ return nil, "", err
+ }
+
+ expiresAt := time.Now().Add(time.Duration(s.sessionDuration) * time.Hour).Unix()
+ _, err = s.sessionRepo.Create(user.ID, token, expiresAt)
+ if err != nil {
+ return nil, "", err
+ }
+
+ ctx := &model.UserContext{
+ User: user,
+ Tenant: tenant,
+ }
+
+ return ctx, token, nil
+}
+
+// Login authenticates a user and returns a session token
+func (s *AuthService) Login(tenantName, username, password string) (*model.UserContext, string, error) {
+ // Get tenant
+ tenant, err := s.tenantRepo.GetByName(tenantName)
+ if err != nil {
+ return nil, "", ErrInvalidCredentials
+ }
+
+ // Get user by username or email
+ var user *model.User
+ user, err = s.userRepo.GetByUsername(tenant.ID, username)
+ if err != nil {
+ // Try by email
+ user, err = s.userRepo.GetByEmail(tenant.ID, username)
+ if err != nil {
+ return nil, "", ErrInvalidCredentials
+ }
+ }
+
+ // Verify password
+ err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password))
+ if err != nil {
+ return nil, "", ErrInvalidCredentials
+ }
+
+ // Create session
+ token, err := s.generateToken()
+ if err != nil {
+ return nil, "", err
+ }
+
+ expiresAt := time.Now().Add(time.Duration(s.sessionDuration) * time.Hour).Unix()
+ _, err = s.sessionRepo.Create(user.ID, token, expiresAt)
+ if err != nil {
+ return nil, "", err
+ }
+
+ ctx := &model.UserContext{
+ User: user,
+ Tenant: tenant,
+ }
+
+ return ctx, token, nil
+}
+
+// ValidateSession validates a session token and returns user context
+func (s *AuthService) ValidateSession(token string) (*model.UserContext, error) {
+ // Get session
+ session, err := s.sessionRepo.GetByToken(token)
+ if err != nil {
+ return nil, err
+ }
+
+ // Get user
+ user, err := s.userRepo.GetByID(session.UserID)
+ if err != nil {
+ return nil, err
+ }
+
+ // Get tenant
+ tenant, err := s.tenantRepo.GetByID(user.TenantID)
+ if err != nil {
+ return nil, err
+ }
+
+ ctx := &model.UserContext{
+ User: user,
+ Tenant: tenant,
+ }
+
+ return ctx, nil
+}
+
+// Logout invalidates a session
+func (s *AuthService) Logout(token string) error {
+ session, err := s.sessionRepo.GetByToken(token)
+ if err != nil {
+ return err
+ }
+
+ return s.sessionRepo.Delete(session)
+}
+
+// generateToken generates a random session token
+func (s *AuthService) generateToken() (string, error) {
+ bytes := make([]byte, 32)
+ if _, err := rand.Read(bytes); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(bytes), nil
+}
\ No newline at end of file
diff --git a/build_v2.sh b/build_v2.sh
new file mode 100644
index 0000000..af9d6ef
--- /dev/null
+++ b/build_v2.sh
@@ -0,0 +1,47 @@
+#!/bin/bash
+
+# Geek-Life v2.0 Build Script
+# Builds multi-tenant version with PostgreSQL support
+
+set -e
+
+VERSION="2.0.0"
+APP_NAME="geek-life"
+BUILD_DIR="builds"
+
+echo "Building Geek-Life v${VERSION} with multi-tenant support..."
+
+# Create build directory
+mkdir -p ${BUILD_DIR}
+
+# Clean previous builds
+rm -f ${BUILD_DIR}/${APP_NAME}_v2_*
+
+# Build for different platforms
+echo "Building for Linux (amd64)..."
+GOOS=linux GOARCH=amd64 go build -ldflags "-X main.version=${VERSION}" -o ${BUILD_DIR}/${APP_NAME}_v2_linux-amd64 app/*.go
+
+echo "Building for Linux (arm64)..."
+GOOS=linux GOARCH=arm64 go build -ldflags "-X main.version=${VERSION}" -o ${BUILD_DIR}/${APP_NAME}_v2_linux-arm64 app/*.go
+
+echo "Building for macOS (amd64)..."
+GOOS=darwin GOARCH=amd64 go build -ldflags "-X main.version=${VERSION}" -o ${BUILD_DIR}/${APP_NAME}_v2_darwin-amd64 app/*.go
+
+echo "Building for macOS (arm64)..."
+GOOS=darwin GOARCH=arm64 go build -ldflags "-X main.version=${VERSION}" -o ${BUILD_DIR}/${APP_NAME}_v2_darwin-arm64 app/*.go
+
+echo "Building for Windows (amd64)..."
+GOOS=windows GOARCH=amd64 go build -ldflags "-X main.version=${VERSION}" -o ${BUILD_DIR}/${APP_NAME}_v2_windows-amd64.exe app/*.go
+
+echo "Building for Windows (arm64)..."
+GOOS=windows GOARCH=arm64 go build -ldflags "-X main.version=${VERSION}" -o ${BUILD_DIR}/${APP_NAME}_v2_windows-arm64.exe app/*.go
+
+echo "Build completed! Binaries are in the ${BUILD_DIR} directory:"
+ls -la ${BUILD_DIR}/${APP_NAME}_v2_*
+
+echo ""
+echo "To run the application:"
+echo "1. Set up PostgreSQL database"
+echo "2. Copy .env.example to .env and configure"
+echo "3. Run migrations: ./${APP_NAME}_v2_* --migrate"
+echo "4. Start the application: ./${APP_NAME}_v2_*"
\ No newline at end of file
diff --git a/config/config.go b/config/config.go
new file mode 100644
index 0000000..0460743
--- /dev/null
+++ b/config/config.go
@@ -0,0 +1,95 @@
+package config
+
+import (
+ "fmt"
+ "os"
+ "strconv"
+
+ "github.com/joho/godotenv"
+)
+
+// Config holds all configuration for the application
+type Config struct {
+ Database DatabaseConfig
+ Auth AuthConfig
+ App AppConfig
+}
+
+// DatabaseConfig holds database configuration
+type DatabaseConfig struct {
+ Host string
+ Port int
+ User string
+ Password string
+ DBName string
+ SSLMode string
+}
+
+// AuthConfig holds authentication configuration
+type AuthConfig struct {
+ JWTSecret string
+ SessionDuration int // in hours
+}
+
+// AppConfig holds general application configuration
+type AppConfig struct {
+ Environment string
+ LogLevel string
+}
+
+// Load loads configuration from environment variables and .env file
+func Load() (*Config, error) {
+ // Load .env file if it exists
+ _ = godotenv.Load()
+
+ config := &Config{
+ Database: DatabaseConfig{
+ Host: getEnv("DB_HOST", "localhost"),
+ Port: getEnvAsInt("DB_PORT", 5432),
+ User: getEnv("DB_USER", "postgres"),
+ Password: getEnv("DB_PASSWORD", ""),
+ DBName: getEnv("DB_NAME", "geeklife"),
+ SSLMode: getEnv("DB_SSLMODE", "disable"),
+ },
+ Auth: AuthConfig{
+ JWTSecret: getEnv("JWT_SECRET", "your-secret-key-change-this"),
+ SessionDuration: getEnvAsInt("SESSION_DURATION", 24),
+ },
+ App: AppConfig{
+ Environment: getEnv("ENVIRONMENT", "development"),
+ LogLevel: getEnv("LOG_LEVEL", "info"),
+ },
+ }
+
+ return config, nil
+}
+
+// GetDSN returns the database connection string
+func (c *Config) GetDSN() string {
+ return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
+ c.Database.Host,
+ c.Database.Port,
+ c.Database.User,
+ c.Database.Password,
+ c.Database.DBName,
+ c.Database.SSLMode,
+ )
+}
+
+// getEnv gets an environment variable with a fallback value
+func getEnv(key, fallback string) string {
+ if value := os.Getenv(key); value != "" {
+ return value
+ }
+ return fallback
+}
+
+// getEnvAsInt gets an environment variable as integer with a fallback value
+func getEnvAsInt(key string, fallback int) int {
+ if value := os.Getenv(key); value != "" {
+ if intVal, err := strconv.Atoi(value); err == nil {
+ return intVal
+ }
+ }
+ return fallback
+}
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..8621597
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,42 @@
+version: '3.8'
+
+services:
+ postgres:
+ image: postgres:15-alpine
+ environment:
+ POSTGRES_DB: geeklife
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: geeklife_password
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ ports:
+ - "5432:5432"
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U postgres"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+
+ geek-life:
+ build: .
+ depends_on:
+ postgres:
+ condition: service_healthy
+ environment:
+ DB_HOST: postgres
+ DB_PORT: 5432
+ DB_USER: postgres
+ DB_PASSWORD: geeklife_password
+ DB_NAME: geeklife
+ DB_SSLMODE: disable
+ JWT_SECRET: your-super-secret-jwt-key-change-in-production
+ SESSION_DURATION: 24
+ ENVIRONMENT: production
+ LOG_LEVEL: info
+ volumes:
+ - ./migrations:/app/migrations
+ command: sh -c "./geek-life --migrate && ./geek-life"
+ restart: unless-stopped
+
+volumes:
+ postgres_data:
\ No newline at end of file
diff --git a/docs/database_schema.md b/docs/database_schema.md
new file mode 100644
index 0000000..1d2b128
--- /dev/null
+++ b/docs/database_schema.md
@@ -0,0 +1,64 @@
+# Database Schema Design for Multi-Tenant Geek-Life
+
+## Overview
+This document outlines the PostgreSQL database schema for the multi-tenant version of Geek-Life.
+
+## Tables
+
+### tenants
+- id (BIGSERIAL PRIMARY KEY)
+- name (VARCHAR(255) NOT NULL)
+- created_at (TIMESTAMP NOT NULL DEFAULT NOW())
+- updated_at (TIMESTAMP NOT NULL DEFAULT NOW())
+
+### users
+- id (BIGSERIAL PRIMARY KEY)
+- tenant_id (BIGINT NOT NULL REFERENCES tenants(id))
+- username (VARCHAR(100) NOT NULL)
+- email (VARCHAR(255) NOT NULL)
+- password_hash (VARCHAR(255) NOT NULL)
+- created_at (TIMESTAMP NOT NULL DEFAULT NOW())
+- updated_at (TIMESTAMP NOT NULL DEFAULT NOW())
+- UNIQUE(tenant_id, username)
+- UNIQUE(tenant_id, email)
+
+### projects
+- id (BIGSERIAL PRIMARY KEY)
+- tenant_id (BIGINT NOT NULL REFERENCES tenants(id))
+- user_id (BIGINT NOT NULL REFERENCES users(id))
+- title (VARCHAR(255) NOT NULL)
+- uuid (UUID NOT NULL DEFAULT gen_random_uuid())
+- created_at (TIMESTAMP NOT NULL DEFAULT NOW())
+- updated_at (TIMESTAMP NOT NULL DEFAULT NOW())
+- INDEX(tenant_id, user_id)
+- UNIQUE(uuid)
+
+### tasks
+- id (BIGSERIAL PRIMARY KEY)
+- tenant_id (BIGINT NOT NULL REFERENCES tenants(id))
+- user_id (BIGINT NOT NULL REFERENCES users(id))
+- project_id (BIGINT NOT NULL REFERENCES projects(id))
+- uuid (UUID NOT NULL DEFAULT gen_random_uuid())
+- title (VARCHAR(500) NOT NULL)
+- details (TEXT)
+- completed (BOOLEAN NOT NULL DEFAULT FALSE)
+- due_date (TIMESTAMP)
+- created_at (TIMESTAMP NOT NULL DEFAULT NOW())
+- updated_at (TIMESTAMP NOT NULL DEFAULT NOW())
+- INDEX(tenant_id, user_id)
+- INDEX(project_id)
+- INDEX(completed)
+- INDEX(due_date)
+- UNIQUE(uuid)
+
+### user_sessions
+- id (BIGSERIAL PRIMARY KEY)
+- user_id (BIGINT NOT NULL REFERENCES users(id))
+- token (VARCHAR(255) NOT NULL UNIQUE)
+- expires_at (TIMESTAMP NOT NULL)
+- created_at (TIMESTAMP NOT NULL DEFAULT NOW())
+- INDEX(token)
+- INDEX(expires_at)
+
+## Row Level Security (RLS)
+All tables will have RLS enabled to ensure users can only access their own data within their tenant.
\ No newline at end of file
diff --git a/go.mod b/go.mod
index 333f117..c69f223 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
module github.com/ajaxray/geek-life
-go 1.14
+go 1.19
require (
github.com/DataDog/zstd v1.4.5 // indirect
@@ -26,4 +26,10 @@ require (
google.golang.org/protobuf v1.25.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
+ // PostgreSQL and authentication dependencies
+ github.com/lib/pq v1.10.9
+ github.com/jmoiron/sqlx v1.3.5
+ golang.org/x/crypto v0.17.0
+ github.com/google/uuid v1.4.0
+ github.com/joho/godotenv v1.4.0
)
diff --git a/migration/migrator.go b/migration/migrator.go
new file mode 100644
index 0000000..a5393a5
--- /dev/null
+++ b/migration/migrator.go
@@ -0,0 +1,155 @@
+package migration
+
+import (
+ "database/sql"
+ "fmt"
+ "io/ioutil"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "github.com/jmoiron/sqlx"
+)
+
+// Migration represents a database migration
+type Migration struct {
+ Version string
+ Name string
+ SQL string
+}
+
+// Migrator handles database migrations
+type Migrator struct {
+ db *sqlx.DB
+ migrationsDir string
+}
+
+// NewMigrator creates a new migrator instance
+func NewMigrator(db *sqlx.DB, migrationsDir string) *Migrator {
+ return &Migrator{
+ db: db,
+ migrationsDir: migrationsDir,
+ }
+}
+
+// Run executes all pending migrations
+func (m *Migrator) Run() error {
+ // Create migrations table if it doesn't exist
+ if err := m.createMigrationsTable(); err != nil {
+ return fmt.Errorf("failed to create migrations table: %w", err)
+ }
+
+ // Get all migration files
+ migrations, err := m.loadMigrations()
+ if err != nil {
+ return fmt.Errorf("failed to load migrations: %w", err)
+ }
+
+ // Get applied migrations
+ appliedMigrations, err := m.getAppliedMigrations()
+ if err != nil {
+ return fmt.Errorf("failed to get applied migrations: %w", err)
+ }
+
+ // Apply pending migrations
+ for _, migration := range migrations {
+ if !contains(appliedMigrations, migration.Version) {
+ if err := m.applyMigration(migration); err != nil {
+ return fmt.Errorf("failed to apply migration %s: %w", migration.Version, err)
+ }
+ fmt.Printf("Applied migration: %s - %s\n", migration.Version, migration.Name)
+ }
+ }
+
+ return nil
+}
+
+// createMigrationsTable creates the migrations tracking table
+func (m *Migrator) createMigrationsTable() error {
+ query := `
+ CREATE TABLE IF NOT EXISTS schema_migrations (
+ version VARCHAR(255) PRIMARY KEY,
+ applied_at TIMESTAMP NOT NULL DEFAULT NOW()
+ )
+ `
+ _, err := m.db.Exec(query)
+ return err
+}
+
+// loadMigrations loads all migration files from the migrations directory
+func (m *Migrator) loadMigrations() ([]Migration, error) {
+ files, err := filepath.Glob(filepath.Join(m.migrationsDir, "*.sql"))
+ if err != nil {
+ return nil, err
+ }
+
+ var migrations []Migration
+ for _, file := range files {
+ content, err := ioutil.ReadFile(file)
+ if err != nil {
+ return nil, err
+ }
+
+ filename := filepath.Base(file)
+ parts := strings.SplitN(filename, "_", 2)
+ if len(parts) != 2 {
+ continue // Skip files that don't match the pattern
+ }
+
+ version := parts[0]
+ name := strings.TrimSuffix(parts[1], ".sql")
+
+ migrations = append(migrations, Migration{
+ Version: version,
+ Name: name,
+ SQL: string(content),
+ })
+ }
+
+ // Sort migrations by version
+ sort.Slice(migrations, func(i, j int) bool {
+ return migrations[i].Version < migrations[j].Version
+ })
+
+ return migrations, nil
+}
+
+// getAppliedMigrations returns a list of applied migration versions
+func (m *Migrator) getAppliedMigrations() ([]string, error) {
+ var versions []string
+ err := m.db.Select(&versions, "SELECT version FROM schema_migrations ORDER BY version")
+ return versions, err
+}
+
+// applyMigration applies a single migration
+func (m *Migrator) applyMigration(migration Migration) error {
+ tx, err := m.db.Beginx()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ // Execute the migration SQL
+ _, err = tx.Exec(migration.SQL)
+ if err != nil {
+ return err
+ }
+
+ // Record the migration as applied
+ _, err = tx.Exec("INSERT INTO schema_migrations (version) VALUES ($1)", migration.Version)
+ if err != nil {
+ return err
+ }
+
+ return tx.Commit()
+}
+
+// contains checks if a slice contains a string
+func contains(slice []string, item string) bool {
+ for _, s := range slice {
+ if s == item {
+ return true
+ }
+ }
+ return false
+}
\ No newline at end of file
diff --git a/migrations/001_initial_schema.sql b/migrations/001_initial_schema.sql
new file mode 100644
index 0000000..68e3227
--- /dev/null
+++ b/migrations/001_initial_schema.sql
@@ -0,0 +1,118 @@
+-- Migration: 001_initial_schema.sql
+-- Create initial schema for multi-tenant geek-life application
+
+-- Enable UUID extension
+CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
+
+-- Create tenants table
+CREATE TABLE IF NOT EXISTS tenants (
+ id BIGSERIAL PRIMARY KEY,
+ name VARCHAR(255) NOT NULL UNIQUE,
+ created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+
+-- Create users table
+CREATE TABLE IF NOT EXISTS users (
+ id BIGSERIAL PRIMARY KEY,
+ tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
+ username VARCHAR(100) NOT NULL,
+ email VARCHAR(255) NOT NULL,
+ password_hash VARCHAR(255) NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ UNIQUE(tenant_id, username),
+ UNIQUE(tenant_id, email)
+);
+
+-- Create projects table
+CREATE TABLE IF NOT EXISTS projects (
+ id BIGSERIAL PRIMARY KEY,
+ tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
+ user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ title VARCHAR(255) NOT NULL,
+ uuid UUID NOT NULL DEFAULT uuid_generate_v4() UNIQUE,
+ created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+
+-- Create tasks table
+CREATE TABLE IF NOT EXISTS tasks (
+ id BIGSERIAL PRIMARY KEY,
+ tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
+ user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
+ uuid UUID NOT NULL DEFAULT uuid_generate_v4() UNIQUE,
+ title VARCHAR(500) NOT NULL,
+ details TEXT,
+ completed BOOLEAN NOT NULL DEFAULT FALSE,
+ due_date TIMESTAMP,
+ created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+
+-- Create user_sessions table
+CREATE TABLE IF NOT EXISTS user_sessions (
+ id BIGSERIAL PRIMARY KEY,
+ user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ token VARCHAR(255) NOT NULL UNIQUE,
+ expires_at TIMESTAMP NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+
+-- Create indexes for better performance
+CREATE INDEX IF NOT EXISTS idx_users_tenant_id ON users(tenant_id);
+CREATE INDEX IF NOT EXISTS idx_projects_tenant_user ON projects(tenant_id, user_id);
+CREATE INDEX IF NOT EXISTS idx_projects_uuid ON projects(uuid);
+CREATE INDEX IF NOT EXISTS idx_tasks_tenant_user ON tasks(tenant_id, user_id);
+CREATE INDEX IF NOT EXISTS idx_tasks_project_id ON tasks(project_id);
+CREATE INDEX IF NOT EXISTS idx_tasks_completed ON tasks(completed);
+CREATE INDEX IF NOT EXISTS idx_tasks_due_date ON tasks(due_date);
+CREATE INDEX IF NOT EXISTS idx_tasks_uuid ON tasks(uuid);
+CREATE INDEX IF NOT EXISTS idx_sessions_token ON user_sessions(token);
+CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON user_sessions(expires_at);
+
+-- Enable Row Level Security
+ALTER TABLE tenants ENABLE ROW LEVEL SECURITY;
+ALTER TABLE users ENABLE ROW LEVEL SECURITY;
+ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
+ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
+ALTER TABLE user_sessions ENABLE ROW LEVEL SECURITY;
+
+-- Create RLS policies (these will be managed by the application)
+-- Users can only see their own tenant's data
+CREATE POLICY tenant_isolation_users ON users
+ USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
+
+CREATE POLICY tenant_isolation_projects ON projects
+ USING (tenant_id = current_setting('app.current_tenant_id')::bigint AND
+ user_id = current_setting('app.current_user_id')::bigint);
+
+CREATE POLICY tenant_isolation_tasks ON tasks
+ USING (tenant_id = current_setting('app.current_tenant_id')::bigint AND
+ user_id = current_setting('app.current_user_id')::bigint);
+
+CREATE POLICY tenant_isolation_sessions ON user_sessions
+ USING (user_id = current_setting('app.current_user_id')::bigint);
+
+-- Create function to update updated_at timestamp
+CREATE OR REPLACE FUNCTION update_updated_at_column()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = NOW();
+ RETURN NEW;
+END;
+$$ language 'plpgsql';
+
+-- Create triggers for updated_at
+CREATE TRIGGER update_tenants_updated_at BEFORE UPDATE ON tenants
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+
+CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+
+CREATE TRIGGER update_projects_updated_at BEFORE UPDATE ON projects
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+
+CREATE TRIGGER update_tasks_updated_at BEFORE UPDATE ON tasks
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
\ No newline at end of file
diff --git a/model/project.go b/model/project.go
index 1d2904b..3398bd9 100644
--- a/model/project.go
+++ b/model/project.go
@@ -1,8 +1,14 @@
package model
+import "time"
+
// Project represent a collection of related tasks (tags of Habitica)
type Project struct {
- ID int64 `storm:"id,increment",json:"id"`
- Title string `storm:"index",json:"title"`
- UUID string `storm:"unique",json:"uuid,omitempty"`
+ ID int64 `storm:"id,increment" db:"id" json:"id"`
+ TenantID int64 `db:"tenant_id" json:"tenant_id"`
+ UserID int64 `db:"user_id" json:"user_id"`
+ Title string `storm:"index" db:"title" json:"title"`
+ UUID string `storm:"unique" db:"uuid" json:"uuid,omitempty"`
+ CreatedAt time.Time `db:"created_at" json:"created_at"`
+ UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
diff --git a/model/task.go b/model/task.go
index 3e87064..7d70f70 100644
--- a/model/task.go
+++ b/model/task.go
@@ -1,12 +1,18 @@
package model
+import "time"
+
// Task represent a task - the building block of the TaskManager app
type Task struct {
- ID int64 `storm:"id,increment",json:"id"`
- ProjectID int64 `storm:"index",json:"project_id"`
- UUID string `storm:"unique",json:"uuid,omitempty"`
- Title string `json:"text"`
- Details string `json:"notes"`
- Completed bool `storm:"index",json:"completed"`
- DueDate int64 `storm:"index",json:"due_date,omitempty"`
+ ID int64 `storm:"id,increment" db:"id" json:"id"`
+ TenantID int64 `db:"tenant_id" json:"tenant_id"`
+ UserID int64 `db:"user_id" json:"user_id"`
+ ProjectID int64 `storm:"index" db:"project_id" json:"project_id"`
+ UUID string `storm:"unique" db:"uuid" json:"uuid,omitempty"`
+ Title string `db:"title" json:"text"`
+ Details string `db:"details" json:"notes"`
+ Completed bool `storm:"index" db:"completed" json:"completed"`
+ DueDate *int64 `storm:"index" db:"due_date" json:"due_date,omitempty"`
+ CreatedAt time.Time `db:"created_at" json:"created_at"`
+ UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
diff --git a/model/user.go b/model/user.go
new file mode 100644
index 0000000..4fb0e16
--- /dev/null
+++ b/model/user.go
@@ -0,0 +1,39 @@
+package model
+
+import (
+ "time"
+)
+
+// Tenant represents a tenant in the multi-tenant system
+type Tenant struct {
+ ID int64 `db:"id" json:"id"`
+ Name string `db:"name" json:"name"`
+ CreatedAt time.Time `db:"created_at" json:"created_at"`
+ UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
+}
+
+// User represents a user within a tenant
+type User struct {
+ ID int64 `db:"id" json:"id"`
+ TenantID int64 `db:"tenant_id" json:"tenant_id"`
+ Username string `db:"username" json:"username"`
+ Email string `db:"email" json:"email"`
+ PasswordHash string `db:"password_hash" json:"-"`
+ CreatedAt time.Time `db:"created_at" json:"created_at"`
+ UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
+}
+
+// UserSession represents a user session for authentication
+type UserSession struct {
+ ID int64 `db:"id" json:"id"`
+ UserID int64 `db:"user_id" json:"user_id"`
+ Token string `db:"token" json:"token"`
+ ExpiresAt time.Time `db:"expires_at" json:"expires_at"`
+ CreatedAt time.Time `db:"created_at" json:"created_at"`
+}
+
+// UserContext holds the current user and tenant information
+type UserContext struct {
+ User *User
+ Tenant *Tenant
+}
\ No newline at end of file
diff --git a/repository/postgres/project.go b/repository/postgres/project.go
new file mode 100644
index 0000000..dd15b40
--- /dev/null
+++ b/repository/postgres/project.go
@@ -0,0 +1,105 @@
+package postgres
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/jmoiron/sqlx"
+ "github.com/ajaxray/geek-life/model"
+)
+
+// ProjectRepository implements the project repository interface for PostgreSQL
+type ProjectRepository struct {
+ db *sqlx.DB
+}
+
+// NewProjectRepository creates a new project repository
+func NewProjectRepository(db *sqlx.DB) *ProjectRepository {
+ return &ProjectRepository{db: db}
+}
+
+// GetAll retrieves all projects for a user
+func (r *ProjectRepository) GetAll(ctx *model.UserContext) ([]model.Project, error) {
+ var projects []model.Project
+ query := `SELECT id, tenant_id, user_id, title, uuid, created_at, updated_at
+ FROM projects WHERE tenant_id = $1 AND user_id = $2 ORDER BY created_at DESC`
+ err := r.db.Select(&projects, query, ctx.Tenant.ID, ctx.User.ID)
+ return projects, err
+}
+
+// GetByID retrieves a project by ID
+func (r *ProjectRepository) GetByID(ctx *model.UserContext, id int64) (model.Project, error) {
+ var project model.Project
+ query := `SELECT id, tenant_id, user_id, title, uuid, created_at, updated_at
+ FROM projects WHERE id = $1 AND tenant_id = $2 AND user_id = $3`
+ err := r.db.Get(&project, query, id, ctx.Tenant.ID, ctx.User.ID)
+ return project, err
+}
+
+// GetByTitle retrieves a project by title
+func (r *ProjectRepository) GetByTitle(ctx *model.UserContext, title string) (model.Project, error) {
+ var project model.Project
+ query := `SELECT id, tenant_id, user_id, title, uuid, created_at, updated_at
+ FROM projects WHERE title = $1 AND tenant_id = $2 AND user_id = $3`
+ err := r.db.Get(&project, query, title, ctx.Tenant.ID, ctx.User.ID)
+ return project, err
+}
+
+// GetByUUID retrieves a project by UUID
+func (r *ProjectRepository) GetByUUID(ctx *model.UserContext, UUID string) (model.Project, error) {
+ var project model.Project
+ query := `SELECT id, tenant_id, user_id, title, uuid, created_at, updated_at
+ FROM projects WHERE uuid = $1 AND tenant_id = $2 AND user_id = $3`
+ err := r.db.Get(&project, query, UUID, ctx.Tenant.ID, ctx.User.ID)
+ return project, err
+}
+
+// Create creates a new project
+func (r *ProjectRepository) Create(ctx *model.UserContext, title, UUID string) (model.Project, error) {
+ if UUID == "" {
+ UUID = uuid.New().String()
+ }
+
+ project := model.Project{
+ TenantID: ctx.Tenant.ID,
+ UserID: ctx.User.ID,
+ Title: title,
+ UUID: UUID,
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ }
+
+ query := `INSERT INTO projects (tenant_id, user_id, title, uuid, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`
+ err := r.db.QueryRow(query, project.TenantID, project.UserID, project.Title, project.UUID, project.CreatedAt, project.UpdatedAt).Scan(&project.ID)
+ if err != nil {
+ return model.Project{}, err
+ }
+
+ return project, nil
+}
+
+// Update updates an existing project
+func (r *ProjectRepository) Update(ctx *model.UserContext, p *model.Project) error {
+ p.UpdatedAt = time.Now()
+ query := `UPDATE projects SET title = $1, updated_at = $2
+ WHERE id = $3 AND tenant_id = $4 AND user_id = $5`
+ _, err := r.db.Exec(query, p.Title, p.UpdatedAt, p.ID, ctx.Tenant.ID, ctx.User.ID)
+ return err
+}
+
+// UpdateField updates a specific field of a project
+func (r *ProjectRepository) UpdateField(ctx *model.UserContext, p *model.Project, field string, value interface{}) error {
+ p.UpdatedAt = time.Now()
+ query := `UPDATE projects SET ` + field + ` = $1, updated_at = $2
+ WHERE id = $3 AND tenant_id = $4 AND user_id = $5`
+ _, err := r.db.Exec(query, value, p.UpdatedAt, p.ID, ctx.Tenant.ID, ctx.User.ID)
+ return err
+}
+
+// Delete deletes a project
+func (r *ProjectRepository) Delete(ctx *model.UserContext, p *model.Project) error {
+ query := `DELETE FROM projects WHERE id = $1 AND tenant_id = $2 AND user_id = $3`
+ _, err := r.db.Exec(query, p.ID, ctx.Tenant.ID, ctx.User.ID)
+ return err
+}
\ No newline at end of file
diff --git a/repository/postgres/session.go b/repository/postgres/session.go
new file mode 100644
index 0000000..17a33ed
--- /dev/null
+++ b/repository/postgres/session.go
@@ -0,0 +1,63 @@
+package postgres
+
+import (
+ "time"
+
+ "github.com/jmoiron/sqlx"
+ "github.com/ajaxray/geek-life/model"
+)
+
+// SessionRepository implements the session repository interface for PostgreSQL
+type SessionRepository struct {
+ db *sqlx.DB
+}
+
+// NewSessionRepository creates a new session repository
+func NewSessionRepository(db *sqlx.DB) *SessionRepository {
+ return &SessionRepository{db: db}
+}
+
+// GetByToken retrieves a session by token
+func (r *SessionRepository) GetByToken(token string) (*model.UserSession, error) {
+ var session model.UserSession
+ query := `SELECT id, user_id, token, expires_at, created_at
+ FROM user_sessions WHERE token = $1 AND expires_at > NOW()`
+ err := r.db.Get(&session, query, token)
+ if err != nil {
+ return nil, err
+ }
+ return &session, nil
+}
+
+// Create creates a new session
+func (r *SessionRepository) Create(userID int64, token string, expiresAt int64) (*model.UserSession, error) {
+ session := &model.UserSession{
+ UserID: userID,
+ Token: token,
+ ExpiresAt: time.Unix(expiresAt, 0),
+ CreatedAt: time.Now(),
+ }
+
+ query := `INSERT INTO user_sessions (user_id, token, expires_at, created_at)
+ VALUES ($1, $2, $3, $4) RETURNING id`
+ err := r.db.QueryRow(query, session.UserID, session.Token, session.ExpiresAt, session.CreatedAt).Scan(&session.ID)
+ if err != nil {
+ return nil, err
+ }
+
+ return session, nil
+}
+
+// Delete deletes a session
+func (r *SessionRepository) Delete(session *model.UserSession) error {
+ query := `DELETE FROM user_sessions WHERE id = $1`
+ _, err := r.db.Exec(query, session.ID)
+ return err
+}
+
+// DeleteExpired deletes all expired sessions
+func (r *SessionRepository) DeleteExpired() error {
+ query := `DELETE FROM user_sessions WHERE expires_at <= NOW()`
+ _, err := r.db.Exec(query)
+ return err
+}
\ No newline at end of file
diff --git a/repository/postgres/task.go b/repository/postgres/task.go
new file mode 100644
index 0000000..c76d092
--- /dev/null
+++ b/repository/postgres/task.go
@@ -0,0 +1,148 @@
+package postgres
+
+import (
+ "strconv"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/jmoiron/sqlx"
+ "github.com/ajaxray/geek-life/model"
+)
+
+// TaskRepository implements the task repository interface for PostgreSQL
+type TaskRepository struct {
+ db *sqlx.DB
+}
+
+// NewTaskRepository creates a new task repository
+func NewTaskRepository(db *sqlx.DB) *TaskRepository {
+ return &TaskRepository{db: db}
+}
+
+// GetAll retrieves all tasks for a user
+func (r *TaskRepository) GetAll(ctx *model.UserContext) ([]model.Task, error) {
+ var tasks []model.Task
+ query := `SELECT id, tenant_id, user_id, project_id, uuid, title, details, completed, due_date, created_at, updated_at
+ FROM tasks WHERE tenant_id = $1 AND user_id = $2 ORDER BY created_at DESC`
+ err := r.db.Select(&tasks, query, ctx.Tenant.ID, ctx.User.ID)
+ return tasks, err
+}
+
+// GetAllByProject retrieves all tasks for a specific project
+func (r *TaskRepository) GetAllByProject(ctx *model.UserContext, project model.Project) ([]model.Task, error) {
+ var tasks []model.Task
+ query := `SELECT id, tenant_id, user_id, project_id, uuid, title, details, completed, due_date, created_at, updated_at
+ FROM tasks WHERE tenant_id = $1 AND user_id = $2 AND project_id = $3 ORDER BY created_at DESC`
+ err := r.db.Select(&tasks, query, ctx.Tenant.ID, ctx.User.ID, project.ID)
+ return tasks, err
+}
+
+// GetAllByDate retrieves all tasks for a specific date
+func (r *TaskRepository) GetAllByDate(ctx *model.UserContext, date time.Time) ([]model.Task, error) {
+ var tasks []model.Task
+ startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
+ endOfDay := startOfDay.Add(24 * time.Hour)
+
+ query := `SELECT id, tenant_id, user_id, project_id, uuid, title, details, completed, due_date, created_at, updated_at
+ FROM tasks WHERE tenant_id = $1 AND user_id = $2 AND due_date >= $3 AND due_date < $4 ORDER BY due_date ASC`
+ err := r.db.Select(&tasks, query, ctx.Tenant.ID, ctx.User.ID, startOfDay, endOfDay)
+ return tasks, err
+}
+
+// GetAllByDateRange retrieves all tasks within a date range
+func (r *TaskRepository) GetAllByDateRange(ctx *model.UserContext, from, to time.Time) ([]model.Task, error) {
+ var tasks []model.Task
+ query := `SELECT id, tenant_id, user_id, project_id, uuid, title, details, completed, due_date, created_at, updated_at
+ FROM tasks WHERE tenant_id = $1 AND user_id = $2 AND due_date >= $3 AND due_date <= $4 ORDER BY due_date ASC`
+ err := r.db.Select(&tasks, query, ctx.Tenant.ID, ctx.User.ID, from, to)
+ return tasks, err
+}
+
+// GetByID retrieves a task by ID (string format for compatibility)
+func (r *TaskRepository) GetByID(ctx *model.UserContext, ID string) (model.Task, error) {
+ var task model.Task
+ id, err := strconv.ParseInt(ID, 10, 64)
+ if err != nil {
+ return task, err
+ }
+
+ query := `SELECT id, tenant_id, user_id, project_id, uuid, title, details, completed, due_date, created_at, updated_at
+ FROM tasks WHERE id = $1 AND tenant_id = $2 AND user_id = $3`
+ err = r.db.Get(&task, query, id, ctx.Tenant.ID, ctx.User.ID)
+ return task, err
+}
+
+// GetByUUID retrieves a task by UUID
+func (r *TaskRepository) GetByUUID(ctx *model.UserContext, UUID string) (model.Task, error) {
+ var task model.Task
+ query := `SELECT id, tenant_id, user_id, project_id, uuid, title, details, completed, due_date, created_at, updated_at
+ FROM tasks WHERE uuid = $1 AND tenant_id = $2 AND user_id = $3`
+ err := r.db.Get(&task, query, UUID, ctx.Tenant.ID, ctx.User.ID)
+ return task, err
+}
+
+// Create creates a new task
+func (r *TaskRepository) Create(ctx *model.UserContext, project model.Project, title, details, UUID string, dueDate *int64) (model.Task, error) {
+ if UUID == "" {
+ UUID = uuid.New().String()
+ }
+
+ task := model.Task{
+ TenantID: ctx.Tenant.ID,
+ UserID: ctx.User.ID,
+ ProjectID: project.ID,
+ UUID: UUID,
+ Title: title,
+ Details: details,
+ Completed: false,
+ DueDate: dueDate,
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ }
+
+ query := `INSERT INTO tasks (tenant_id, user_id, project_id, uuid, title, details, completed, due_date, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`
+
+ var dueDateValue interface{}
+ if dueDate != nil {
+ dueDateValue = time.Unix(*dueDate, 0)
+ }
+
+ err := r.db.QueryRow(query, task.TenantID, task.UserID, task.ProjectID, task.UUID, task.Title, task.Details, task.Completed, dueDateValue, task.CreatedAt, task.UpdatedAt).Scan(&task.ID)
+ if err != nil {
+ return model.Task{}, err
+ }
+
+ return task, nil
+}
+
+// Update updates an existing task
+func (r *TaskRepository) Update(ctx *model.UserContext, t *model.Task) error {
+ t.UpdatedAt = time.Now()
+
+ var dueDateValue interface{}
+ if t.DueDate != nil {
+ dueDateValue = time.Unix(*t.DueDate, 0)
+ }
+
+ query := `UPDATE tasks SET title = $1, details = $2, completed = $3, due_date = $4, updated_at = $5
+ WHERE id = $6 AND tenant_id = $7 AND user_id = $8`
+ _, err := r.db.Exec(query, t.Title, t.Details, t.Completed, dueDateValue, t.UpdatedAt, t.ID, ctx.Tenant.ID, ctx.User.ID)
+ return err
+}
+
+// UpdateField updates a specific field of a task
+func (r *TaskRepository) UpdateField(ctx *model.UserContext, t *model.Task, field string, value interface{}) error {
+ t.UpdatedAt = time.Now()
+ query := `UPDATE tasks SET ` + field + ` = $1, updated_at = $2
+ WHERE id = $3 AND tenant_id = $4 AND user_id = $5`
+ _, err := r.db.Exec(query, value, t.UpdatedAt, t.ID, ctx.Tenant.ID, ctx.User.ID)
+ return err
+}
+
+// Delete deletes a task
+func (r *TaskRepository) Delete(ctx *model.UserContext, t *model.Task) error {
+ query := `DELETE FROM tasks WHERE id = $1 AND tenant_id = $2 AND user_id = $3`
+ _, err := r.db.Exec(query, t.ID, ctx.Tenant.ID, ctx.User.ID)
+ return err
+}
\ No newline at end of file
diff --git a/repository/postgres/tenant.go b/repository/postgres/tenant.go
new file mode 100644
index 0000000..eb1d0bb
--- /dev/null
+++ b/repository/postgres/tenant.go
@@ -0,0 +1,72 @@
+package postgres
+
+import (
+ "time"
+
+ "github.com/jmoiron/sqlx"
+ "github.com/ajaxray/geek-life/model"
+)
+
+// TenantRepository implements the tenant repository interface for PostgreSQL
+type TenantRepository struct {
+ db *sqlx.DB
+}
+
+// NewTenantRepository creates a new tenant repository
+func NewTenantRepository(db *sqlx.DB) *TenantRepository {
+ return &TenantRepository{db: db}
+}
+
+// GetByID retrieves a tenant by ID
+func (r *TenantRepository) GetByID(id int64) (*model.Tenant, error) {
+ var tenant model.Tenant
+ query := `SELECT id, name, created_at, updated_at FROM tenants WHERE id = $1`
+ err := r.db.Get(&tenant, query, id)
+ if err != nil {
+ return nil, err
+ }
+ return &tenant, nil
+}
+
+// GetByName retrieves a tenant by name
+func (r *TenantRepository) GetByName(name string) (*model.Tenant, error) {
+ var tenant model.Tenant
+ query := `SELECT id, name, created_at, updated_at FROM tenants WHERE name = $1`
+ err := r.db.Get(&tenant, query, name)
+ if err != nil {
+ return nil, err
+ }
+ return &tenant, nil
+}
+
+// Create creates a new tenant
+func (r *TenantRepository) Create(name string) (*model.Tenant, error) {
+ tenant := &model.Tenant{
+ Name: name,
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ }
+
+ query := `INSERT INTO tenants (name, created_at, updated_at) VALUES ($1, $2, $3) RETURNING id`
+ err := r.db.QueryRow(query, tenant.Name, tenant.CreatedAt, tenant.UpdatedAt).Scan(&tenant.ID)
+ if err != nil {
+ return nil, err
+ }
+
+ return tenant, nil
+}
+
+// Update updates an existing tenant
+func (r *TenantRepository) Update(tenant *model.Tenant) error {
+ tenant.UpdatedAt = time.Now()
+ query := `UPDATE tenants SET name = $1, updated_at = $2 WHERE id = $3`
+ _, err := r.db.Exec(query, tenant.Name, tenant.UpdatedAt, tenant.ID)
+ return err
+}
+
+// Delete deletes a tenant
+func (r *TenantRepository) Delete(tenant *model.Tenant) error {
+ query := `DELETE FROM tenants WHERE id = $1`
+ _, err := r.db.Exec(query, tenant.ID)
+ return err
+}
\ No newline at end of file
diff --git a/repository/postgres/user.go b/repository/postgres/user.go
new file mode 100644
index 0000000..365ef9b
--- /dev/null
+++ b/repository/postgres/user.go
@@ -0,0 +1,91 @@
+package postgres
+
+import (
+ "time"
+
+ "github.com/jmoiron/sqlx"
+ "github.com/ajaxray/geek-life/model"
+)
+
+// UserRepository implements the user repository interface for PostgreSQL
+type UserRepository struct {
+ db *sqlx.DB
+}
+
+// NewUserRepository creates a new user repository
+func NewUserRepository(db *sqlx.DB) *UserRepository {
+ return &UserRepository{db: db}
+}
+
+// GetByID retrieves a user by ID
+func (r *UserRepository) GetByID(id int64) (*model.User, error) {
+ var user model.User
+ query := `SELECT id, tenant_id, username, email, password_hash, created_at, updated_at
+ FROM users WHERE id = $1`
+ err := r.db.Get(&user, query, id)
+ if err != nil {
+ return nil, err
+ }
+ return &user, nil
+}
+
+// GetByUsername retrieves a user by username within a tenant
+func (r *UserRepository) GetByUsername(tenantID int64, username string) (*model.User, error) {
+ var user model.User
+ query := `SELECT id, tenant_id, username, email, password_hash, created_at, updated_at
+ FROM users WHERE tenant_id = $1 AND username = $2`
+ err := r.db.Get(&user, query, tenantID, username)
+ if err != nil {
+ return nil, err
+ }
+ return &user, nil
+}
+
+// GetByEmail retrieves a user by email within a tenant
+func (r *UserRepository) GetByEmail(tenantID int64, email string) (*model.User, error) {
+ var user model.User
+ query := `SELECT id, tenant_id, username, email, password_hash, created_at, updated_at
+ FROM users WHERE tenant_id = $1 AND email = $2`
+ err := r.db.Get(&user, query, tenantID, email)
+ if err != nil {
+ return nil, err
+ }
+ return &user, nil
+}
+
+// Create creates a new user
+func (r *UserRepository) Create(tenantID int64, username, email, passwordHash string) (*model.User, error) {
+ user := &model.User{
+ TenantID: tenantID,
+ Username: username,
+ Email: email,
+ PasswordHash: passwordHash,
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ }
+
+ query := `INSERT INTO users (tenant_id, username, email, password_hash, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`
+ err := r.db.QueryRow(query, user.TenantID, user.Username, user.Email, user.PasswordHash, user.CreatedAt, user.UpdatedAt).Scan(&user.ID)
+ if err != nil {
+ return nil, err
+ }
+
+ return user, nil
+}
+
+// Update updates an existing user
+func (r *UserRepository) Update(user *model.User) error {
+ user.UpdatedAt = time.Now()
+ query := `UPDATE users SET username = $1, email = $2, password_hash = $3, updated_at = $4
+ WHERE id = $5`
+ _, err := r.db.Exec(query, user.Username, user.Email, user.PasswordHash, user.UpdatedAt, user.ID)
+ return err
+}
+
+// Delete deletes a user
+func (r *UserRepository) Delete(user *model.User) error {
+ query := `DELETE FROM users WHERE id = $1`
+ _, err := r.db.Exec(query, user.ID)
+ return err
+}
\ No newline at end of file
diff --git a/repository/project.go b/repository/project.go
index 3e28fed..d33fda7 100644
--- a/repository/project.go
+++ b/repository/project.go
@@ -4,12 +4,12 @@ import "github.com/ajaxray/geek-life/model"
// ProjectRepository interface defines methods of project data accessor
type ProjectRepository interface {
- GetAll() ([]model.Project, error)
- GetByID(id int64) (model.Project, error)
- GetByTitle(title string) (model.Project, error)
- GetByUUID(UUID string) (model.Project, error)
- Create(title, UUID string) (model.Project, error)
- Update(p *model.Project) error
- UpdateField(p *model.Project, field string, value interface{}) error
- Delete(p *model.Project) error
+ GetAll(ctx *model.UserContext) ([]model.Project, error)
+ GetByID(ctx *model.UserContext, id int64) (model.Project, error)
+ GetByTitle(ctx *model.UserContext, title string) (model.Project, error)
+ GetByUUID(ctx *model.UserContext, UUID string) (model.Project, error)
+ Create(ctx *model.UserContext, title, UUID string) (model.Project, error)
+ Update(ctx *model.UserContext, p *model.Project) error
+ UpdateField(ctx *model.UserContext, p *model.Project, field string, value interface{}) error
+ Delete(ctx *model.UserContext, p *model.Project) error
}
diff --git a/repository/task.go b/repository/task.go
index b88516c..7467180 100644
--- a/repository/task.go
+++ b/repository/task.go
@@ -8,14 +8,14 @@ import (
// TaskRepository interface defines methods of task data accessor
type TaskRepository interface {
- GetAll() ([]model.Task, error)
- GetAllByProject(project model.Project) ([]model.Task, error)
- GetAllByDate(date time.Time) ([]model.Task, error)
- GetAllByDateRange(from, to time.Time) ([]model.Task, error)
- GetByID(ID string) (model.Task, error)
- GetByUUID(UUID string) (model.Task, error)
- Create(project model.Project, title, details, UUID string, dueDate int64) (model.Task, error)
- Update(t *model.Task) error
- UpdateField(t *model.Task, field string, value interface{}) error
- Delete(t *model.Task) error
+ GetAll(ctx *model.UserContext) ([]model.Task, error)
+ GetAllByProject(ctx *model.UserContext, project model.Project) ([]model.Task, error)
+ GetAllByDate(ctx *model.UserContext, date time.Time) ([]model.Task, error)
+ GetAllByDateRange(ctx *model.UserContext, from, to time.Time) ([]model.Task, error)
+ GetByID(ctx *model.UserContext, ID string) (model.Task, error)
+ GetByUUID(ctx *model.UserContext, UUID string) (model.Task, error)
+ Create(ctx *model.UserContext, project model.Project, title, details, UUID string, dueDate *int64) (model.Task, error)
+ Update(ctx *model.UserContext, t *model.Task) error
+ UpdateField(ctx *model.UserContext, t *model.Task, field string, value interface{}) error
+ Delete(ctx *model.UserContext, t *model.Task) error
}
diff --git a/repository/user.go b/repository/user.go
new file mode 100644
index 0000000..b13e747
--- /dev/null
+++ b/repository/user.go
@@ -0,0 +1,30 @@
+package repository
+
+import "github.com/ajaxray/geek-life/model"
+
+// UserRepository interface defines methods for user data access
+type UserRepository interface {
+ GetByID(id int64) (*model.User, error)
+ GetByUsername(tenantID int64, username string) (*model.User, error)
+ GetByEmail(tenantID int64, email string) (*model.User, error)
+ Create(tenantID int64, username, email, passwordHash string) (*model.User, error)
+ Update(user *model.User) error
+ Delete(user *model.User) error
+}
+
+// TenantRepository interface defines methods for tenant data access
+type TenantRepository interface {
+ GetByID(id int64) (*model.Tenant, error)
+ GetByName(name string) (*model.Tenant, error)
+ Create(name string) (*model.Tenant, error)
+ Update(tenant *model.Tenant) error
+ Delete(tenant *model.Tenant) error
+}
+
+// SessionRepository interface defines methods for session management
+type SessionRepository interface {
+ GetByToken(token string) (*model.UserSession, error)
+ Create(userID int64, token string, expiresAt int64) (*model.UserSession, error)
+ Delete(session *model.UserSession) error
+ DeleteExpired() error
+}
\ No newline at end of file
diff --git a/util/database.go b/util/database.go
new file mode 100644
index 0000000..d9a1cf4
--- /dev/null
+++ b/util/database.go
@@ -0,0 +1,58 @@
+package util
+
+import (
+ "log"
+ "os"
+ "path/filepath"
+
+ "github.com/asdine/storm/v3"
+ "github.com/jmoiron/sqlx"
+ "github.com/mitchellh/go-homedir"
+ _ "github.com/lib/pq"
+)
+
+// ConnectStorm connects to Storm database (legacy support)
+func ConnectStorm(dbFile string) *storm.DB {
+ if dbFile == "" {
+ home, err := homedir.Dir()
+ LogIfError(err, "Could not detect home directory")
+
+ dbFile = filepath.Join(home, ".geek-life", "geek-life.db")
+ }
+
+ // Create directory if not exists
+ dir := filepath.Dir(dbFile)
+ if _, err := os.Stat(dir); os.IsNotExist(err) {
+ err = os.MkdirAll(dir, 0755)
+ LogIfError(err, "Could not create directory for DB file")
+ }
+
+ db, err := storm.Open(dbFile)
+ if err != nil {
+ log.Fatalf("Could not open storm DB: %v", err)
+ }
+
+ return db
+}
+
+// ConnectPostgres connects to PostgreSQL database
+func ConnectPostgres(dsn string) *sqlx.DB {
+ db, err := sqlx.Connect("postgres", dsn)
+ if err != nil {
+ log.Fatalf("Could not connect to PostgreSQL: %v", err)
+ }
+
+ // Test the connection
+ if err := db.Ping(); err != nil {
+ log.Fatalf("Could not ping PostgreSQL: %v", err)
+ }
+
+ return db
+}
+
+// LogIfError logs error if it's not nil
+func LogIfError(err error, message string) {
+ if err != nil {
+ log.Printf("%s: %v", message, err)
+ }
+}
\ No newline at end of file