Compare commits
2 Commits
developmen
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| aee884a586 | |||
| 1fbb202002 |
15
.env.example
Normal file
15
.env.example
Normal file
@@ -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
|
||||
33
Dockerfile
Normal file
33
Dockerfile
Normal file
@@ -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"]
|
||||
204
README_v2.md
Normal file
204
README_v2.md
Normal file
@@ -0,0 +1,204 @@
|
||||
# Geek-Life v2.0 - Multi-Tenant CLI Task Manager
|
||||
|
||||
<p align="center">
|
||||
<img src="media/geek-life-logo.png" align="center" alt="Geek-life Logo">
|
||||
</p>
|
||||
|
||||
## 🚀 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! 🚀
|
||||
292
app/main.go
Normal file
292
app/main.go
Normal file
@@ -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)
|
||||
}
|
||||
229
auth/service.go
Normal file
229
auth/service.go
Normal file
@@ -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
|
||||
}
|
||||
47
build_v2.sh
Normal file
47
build_v2.sh
Normal file
@@ -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_*"
|
||||
95
config/config.go
Normal file
95
config/config.go
Normal file
@@ -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
|
||||
}
|
||||
42
docker-compose.yml
Normal file
42
docker-compose.yml
Normal file
@@ -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:
|
||||
64
docs/database_schema.md
Normal file
64
docs/database_schema.md
Normal file
@@ -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.
|
||||
8
go.mod
8
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
|
||||
)
|
||||
|
||||
155
migration/migrator.go
Normal file
155
migration/migrator.go
Normal file
@@ -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
|
||||
}
|
||||
118
migrations/001_initial_schema.sql
Normal file
118
migrations/001_initial_schema.sql
Normal file
@@ -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();
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
39
model/user.go
Normal file
39
model/user.go
Normal file
@@ -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
|
||||
}
|
||||
105
repository/postgres/project.go
Normal file
105
repository/postgres/project.go
Normal file
@@ -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
|
||||
}
|
||||
63
repository/postgres/session.go
Normal file
63
repository/postgres/session.go
Normal file
@@ -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
|
||||
}
|
||||
148
repository/postgres/task.go
Normal file
148
repository/postgres/task.go
Normal file
@@ -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
|
||||
}
|
||||
72
repository/postgres/tenant.go
Normal file
72
repository/postgres/tenant.go
Normal file
@@ -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
|
||||
}
|
||||
91
repository/postgres/user.go
Normal file
91
repository/postgres/user.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
30
repository/user.go
Normal file
30
repository/user.go
Normal file
@@ -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
|
||||
}
|
||||
58
util/database.go
Normal file
58
util/database.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user