From 1fbb202002afe087630ac017cf697da2901f6899 Mon Sep 17 00:00:00 2001 From: Othman Hendy Suseno Date: Sun, 12 Oct 2025 15:47:00 +0700 Subject: [PATCH] build multi tenant --- .env.example | 15 ++ Dockerfile | 33 ++++ README_v2.md | 204 +++++++++++++++++++++ app/main.go | 292 ++++++++++++++++++++++++++++++ auth/service.go | 229 +++++++++++++++++++++++ build_v2.sh | 47 +++++ config/config.go | 95 ++++++++++ docker-compose.yml | 42 +++++ docs/database_schema.md | 64 +++++++ go.mod | 8 +- migration/migrator.go | 155 ++++++++++++++++ migrations/001_initial_schema.sql | 118 ++++++++++++ model/project.go | 12 +- model/task.go | 20 +- model/user.go | 39 ++++ repository/postgres/project.go | 105 +++++++++++ repository/postgres/session.go | 63 +++++++ repository/postgres/task.go | 148 +++++++++++++++ repository/postgres/tenant.go | 72 ++++++++ repository/postgres/user.go | 91 ++++++++++ repository/project.go | 16 +- repository/task.go | 20 +- repository/user.go | 30 +++ util/database.go | 58 ++++++ 24 files changed, 1947 insertions(+), 29 deletions(-) create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 README_v2.md create mode 100644 app/main.go create mode 100644 auth/service.go create mode 100644 build_v2.sh create mode 100644 config/config.go create mode 100644 docker-compose.yml create mode 100644 docs/database_schema.md create mode 100644 migration/migrator.go create mode 100644 migrations/001_initial_schema.sql create mode 100644 model/user.go create mode 100644 repository/postgres/project.go create mode 100644 repository/postgres/session.go create mode 100644 repository/postgres/task.go create mode 100644 repository/postgres/tenant.go create mode 100644 repository/postgres/user.go create mode 100644 repository/user.go create mode 100644 util/database.go 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 + +

+ Geek-life Logo +

+ +## 🚀 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