Compare commits
12 Commits
eaba2373d3
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| aee884a586 | |||
| 1fbb202002 | |||
|
|
460934af65 | ||
|
|
0dedb1a7d9 | ||
|
|
8ac35a2c81 | ||
|
|
df53721939 | ||
|
|
ca0479b28a | ||
|
|
e466e2f801 | ||
|
|
e4132138ab | ||
|
|
9233c89ab6 | ||
|
|
21defdafdc | ||
|
|
923f11be3e |
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
|
||||
87
BUILD-WINDOWS.md
Normal file
87
BUILD-WINDOWS.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Building Geek-life for Windows
|
||||
|
||||
This document explains how to build and run Geek-life on Windows systems.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Go 1.14 or later installed on your system
|
||||
- PowerShell (comes with Windows)
|
||||
|
||||
## Building the Application
|
||||
|
||||
### Option 1: Using the Windows Build Script (Recommended)
|
||||
|
||||
Run the PowerShell build script to build for all Windows architectures:
|
||||
|
||||
```powershell
|
||||
.\build-windows.ps1
|
||||
```
|
||||
|
||||
This will create optimized executables for:
|
||||
- Windows AMD64 (64-bit) - `geek-life_windows-amd64.exe`
|
||||
- Windows 386 (32-bit) - `geek-life_windows-386.exe`
|
||||
- Windows ARM64 - `geek-life_windows-arm64.exe`
|
||||
|
||||
### Option 2: Manual Build
|
||||
|
||||
For a simple build without optimization:
|
||||
```powershell
|
||||
go build -o builds/geek-life.exe ./app
|
||||
```
|
||||
|
||||
For an optimized build (smaller file size):
|
||||
```powershell
|
||||
$env:GOOS="windows"
|
||||
$env:GOARCH="amd64"
|
||||
go build -ldflags="-s -w" -o builds/geek-life_windows-amd64.exe ./app
|
||||
```
|
||||
|
||||
## Running the Application
|
||||
|
||||
After building, you can run the application:
|
||||
|
||||
```powershell
|
||||
# For the optimized AMD64 version (recommended for most users)
|
||||
.\builds\geek-life_windows-amd64.exe
|
||||
|
||||
# For 32-bit systems
|
||||
.\builds\geek-life_windows-386.exe
|
||||
|
||||
# For ARM64 systems
|
||||
.\builds\geek-life_windows-arm64.exe
|
||||
```
|
||||
|
||||
## Command Line Options
|
||||
|
||||
```powershell
|
||||
.\builds\geek-life_windows-amd64.exe --help
|
||||
```
|
||||
|
||||
Available options:
|
||||
- `-d, --db-file string`: Specify DB file path manually
|
||||
|
||||
## File Structure
|
||||
|
||||
The main entry point is located in `app/cli.go` (not in a traditional `main.go` file at the root).
|
||||
|
||||
## Build Optimization
|
||||
|
||||
The build script uses the following optimization flags:
|
||||
- `-ldflags="-s -w"`: Strips debug information and symbol table to reduce file size
|
||||
- Results in approximately 30% smaller executable files
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
1. **"go: command not found"**: Make sure Go is installed and added to your PATH
|
||||
2. **Permission denied**: Run PowerShell as Administrator if needed
|
||||
3. **Execution policy**: If you can't run the build script, run: `Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser`
|
||||
|
||||
## Cross-compilation from Other Platforms
|
||||
|
||||
You can also build Windows executables from Linux/macOS:
|
||||
|
||||
```bash
|
||||
# From Linux/macOS
|
||||
env GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o builds/geek-life_windows-amd64.exe ./app
|
||||
env GOOS=windows GOARCH=386 go build -ldflags="-s -w" -o builds/geek-life_windows-386.exe ./app
|
||||
```
|
||||
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"]
|
||||
15
README.md
15
README.md
@@ -168,11 +168,16 @@ I will be glad to accept your PR. :)
|
||||
By default, it will try to create a db file in you home directory.
|
||||
|
||||
But as a geek, you may try to put it different location (e,g, in your dropbox for syncing).
|
||||
In that case, just mention `DB_FILE` as an environment variable.
|
||||
|
||||
In that case, just mention `DB_FILE` as an environment variable.
|
||||
```bash
|
||||
DB_FILE=~/dropbox/geek-life/default.db geek-life
|
||||
```
|
||||
**UPDATE:** For Windows users, setting ENV variable is not so straight forward.
|
||||
So, added a flag `--db-file` or `-d` to specify DB file path from command line easily.
|
||||
```bash
|
||||
geek-life --db-file=D:\a-writable-dir\tasks.db
|
||||
```
|
||||
|
||||
|
||||
#### :question: How can I suggest a feature?
|
||||
|
||||
@@ -182,6 +187,12 @@ and select `feature` label.
|
||||
Also, incomplete features in the current roadmap will be found in issue list.
|
||||
You may :thumbsup: issues if you want to increase priority of a feature.
|
||||
|
||||
---
|
||||
[](https://www.jetbrains.com/)
|
||||
Developed with [GoLand](https://www.jetbrains.com/go/).
|
||||
Thanks to [JetBrains](https://www.jetbrains.com/) for sponsoring [Licenses for Open Source Development](https://www.jetbrains.com/community/opensource/#support).
|
||||
|
||||
|
||||
---
|
||||
### Footnotes
|
||||
1. In my Macbook Air, 1.6 GHz Dual-Core Intel Core i5, RAM: 8 GB 1600 MHz DDR3
|
||||
|
||||
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! 🚀
|
||||
40
app/cli.go
40
app/cli.go
@@ -3,10 +3,12 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"unicode"
|
||||
|
||||
"github.com/asdine/storm/v3"
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
flag "github.com/spf13/pflag"
|
||||
|
||||
"github.com/ajaxray/geek-life/model"
|
||||
"github.com/ajaxray/geek-life/repository"
|
||||
@@ -27,20 +29,29 @@ var (
|
||||
db *storm.DB
|
||||
projectRepo repository.ProjectRepository
|
||||
taskRepo repository.TaskRepository
|
||||
|
||||
// Flag variables
|
||||
dbFile string
|
||||
)
|
||||
|
||||
func init() {
|
||||
flag.StringVarP(&dbFile, "db-file", "d", "", "Specify DB file path manually.")
|
||||
}
|
||||
|
||||
func main() {
|
||||
app = tview.NewApplication()
|
||||
flag.Parse()
|
||||
|
||||
db = util.ConnectStorm()
|
||||
db = util.ConnectStorm(dbFile)
|
||||
defer func() {
|
||||
if err := db.Close(); err != nil {
|
||||
util.LogIfError(err, "Error in closing storm Db")
|
||||
}
|
||||
}()
|
||||
|
||||
if len(os.Args) > 1 && os.Args[1] == "migrate" {
|
||||
if flag.NArg() > 0 && flag.Arg(0) == "migrate" {
|
||||
migrate(db)
|
||||
fmt.Println("Database migrated successfully!")
|
||||
} else {
|
||||
projectRepo = repo.NewProjectRepository(db)
|
||||
taskRepo = repo.NewTaskRepository(db)
|
||||
@@ -74,12 +85,15 @@ func setKeyboardShortcuts() *tview.Application {
|
||||
}
|
||||
|
||||
// Global shortcuts
|
||||
switch event.Rune() {
|
||||
switch unicode.ToLower(event.Rune()) {
|
||||
case 'p':
|
||||
app.SetFocus(projectPane)
|
||||
contents.RemoveItem(taskDetailPane)
|
||||
return nil
|
||||
case 'q':
|
||||
case 't':
|
||||
app.SetFocus(taskPane)
|
||||
contents.RemoveItem(taskDetailPane)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -122,3 +136,23 @@ func makeTitleBar() *tview.Flex {
|
||||
AddItem(titleText, 0, 2, false).
|
||||
AddItem(versionInfo, 0, 1, false)
|
||||
}
|
||||
|
||||
func AskYesNo(text string, f func()) {
|
||||
|
||||
activePane := app.GetFocus()
|
||||
modal := tview.NewModal().
|
||||
SetText(text).
|
||||
AddButtons([]string{"Yes", "No"}).
|
||||
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||
if buttonLabel == "Yes" {
|
||||
f()
|
||||
}
|
||||
app.SetRoot(layout, true).EnableMouse(true)
|
||||
app.SetFocus(activePane)
|
||||
})
|
||||
|
||||
pages := tview.NewPages().
|
||||
AddPage("background", layout, true, true).
|
||||
AddPage("modal", modal, true, true)
|
||||
_ = app.SetRoot(pages, true).EnableMouse(true)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"unicode"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
@@ -13,13 +15,21 @@ type ProjectDetailPane struct {
|
||||
project *model.Project
|
||||
}
|
||||
|
||||
func removeProjectWithConfirmation() {
|
||||
AskYesNo("Do you want to delete Project?", projectPane.RemoveActivateProject)
|
||||
}
|
||||
|
||||
func clearCompletedWithConfirmation() {
|
||||
AskYesNo("Do you want to clear completed tasks?", taskPane.ClearCompletedTasks)
|
||||
}
|
||||
|
||||
// NewProjectDetailPane Initializes ProjectDetailPane
|
||||
func NewProjectDetailPane() *ProjectDetailPane {
|
||||
pane := ProjectDetailPane{
|
||||
Flex: tview.NewFlex().SetDirection(tview.FlexRow),
|
||||
}
|
||||
deleteBtn := makeButton("[::u]D[::-]elete Project", projectPane.RemoveActivateProject)
|
||||
clearBtn := makeButton("[::u]C[::-]lear Completed Tasks", taskPane.ClearCompletedTasks)
|
||||
deleteBtn := makeButton("[::u]D[::-]elete Project", removeProjectWithConfirmation)
|
||||
clearBtn := makeButton("[::u]C[::-]lear Completed Tasks", clearCompletedWithConfirmation)
|
||||
|
||||
deleteBtn.SetBackgroundColor(tcell.ColorRed)
|
||||
pane.
|
||||
@@ -44,12 +54,12 @@ func (pd *ProjectDetailPane) isShowing() bool {
|
||||
}
|
||||
|
||||
func (pd *ProjectDetailPane) handleShortcuts(event *tcell.EventKey) *tcell.EventKey {
|
||||
switch event.Rune() {
|
||||
switch unicode.ToLower(event.Rune()) {
|
||||
case 'd':
|
||||
projectPane.RemoveActivateProject()
|
||||
removeProjectWithConfirmation()
|
||||
return nil
|
||||
case 'c':
|
||||
taskPane.ClearCompletedTasks()
|
||||
clearCompletedWithConfirmation()
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
@@ -107,11 +108,11 @@ func (pane *ProjectPane) addProjectToList(i int, selectItem bool) {
|
||||
|
||||
func (pane *ProjectPane) addSection(name string) {
|
||||
pane.list.AddItem("[::d]"+name, "", 0, nil)
|
||||
pane.list.AddItem("[::d]"+strings.Repeat(string(tcell.RuneS3), 25), "", 0, nil)
|
||||
pane.list.AddItem("[::d]"+strings.Repeat(string(tcell.RuneHLine), 25), "", 0, nil)
|
||||
}
|
||||
|
||||
func (pane *ProjectPane) handleShortcuts(event *tcell.EventKey) *tcell.EventKey {
|
||||
switch event.Rune() {
|
||||
switch unicode.ToLower(event.Rune()) {
|
||||
case 'j':
|
||||
pane.list.SetCurrentItem(pane.list.GetCurrentItem() + 1)
|
||||
return nil
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/atotto/clipboard"
|
||||
"github.com/gdamore/tcell/v2"
|
||||
@@ -89,9 +90,9 @@ func (td *TaskDetailPane) Export() {
|
||||
}
|
||||
content.WriteString("\n" + td.task.Details + " \n")
|
||||
|
||||
clipboard.WriteAll(content.String())
|
||||
_ = clipboard.WriteAll(content.String())
|
||||
app.SetFocus(td)
|
||||
statusBar.showForSeconds("Task copyed. Try Pasting anywhere.", 5)
|
||||
statusBar.showForSeconds("Task copied. Try Pasting anywhere.", 5)
|
||||
}
|
||||
|
||||
func (td *TaskDetailPane) makeDateRow() *tview.Flex {
|
||||
@@ -288,7 +289,9 @@ func writeToTmpFile(content string) (string, error) {
|
||||
func (td *TaskDetailPane) handleShortcuts(event *tcell.EventKey) *tcell.EventKey {
|
||||
switch event.Key() {
|
||||
case tcell.KeyEsc:
|
||||
removeThirdCol()
|
||||
app.SetFocus(taskPane)
|
||||
contents.AddItem(projectDetailPane, 25, 0, false)
|
||||
return nil
|
||||
case tcell.KeyDown:
|
||||
td.taskDetailView.ScrollDown(1)
|
||||
@@ -297,7 +300,7 @@ func (td *TaskDetailPane) handleShortcuts(event *tcell.EventKey) *tcell.EventKey
|
||||
td.taskDetailView.ScrollUp(1)
|
||||
return nil
|
||||
case tcell.KeyRune:
|
||||
switch event.Rune() {
|
||||
switch unicode.ToLower(event.Rune()) {
|
||||
case 'e':
|
||||
td.activateEditor()
|
||||
return nil
|
||||
|
||||
@@ -45,7 +45,7 @@ func NewTaskDetailHeader(taskRepo repository.TaskRepository) *TaskDetailHeader {
|
||||
AddItem(header.pages, 1, 1, true).
|
||||
AddItem(blankCell, 1, 0, true).
|
||||
AddItem(buttons, 1, 1, false).
|
||||
AddItem(makeHorizontalLine(tcell.RuneS3, tcell.ColorGray), 1, 1, false)
|
||||
AddItem(makeHorizontalLine(tcell.RuneHLine, tcell.ColorGray), 1, 1, false)
|
||||
|
||||
return &header
|
||||
}
|
||||
|
||||
11
app/tasks.go
11
app/tasks.go
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/asdine/storm/v3"
|
||||
"github.com/gdamore/tcell/v2"
|
||||
@@ -102,7 +103,7 @@ func (pane *TaskPane) addTaskToList(i int) *tview.List {
|
||||
}
|
||||
|
||||
func (pane *TaskPane) handleShortcuts(event *tcell.EventKey) *tcell.EventKey {
|
||||
switch event.Rune() {
|
||||
switch unicode.ToLower(event.Rune()) {
|
||||
case 'j':
|
||||
pane.list.SetCurrentItem(pane.list.GetCurrentItem() + 1)
|
||||
return nil
|
||||
@@ -147,21 +148,21 @@ func (pane *TaskPane) LoadDynamicList(logic string) {
|
||||
switch logic {
|
||||
case "today":
|
||||
tasks, err = pane.taskRepo.GetAllByDateRange(zeroTime, today)
|
||||
rangeDesc = fmt.Sprintf("Today (and overdue)")
|
||||
rangeDesc = "Today (and overdue)"
|
||||
|
||||
case "tomorrow":
|
||||
tomorrow := today.AddDate(0, 0, 1)
|
||||
tasks, err = pane.taskRepo.GetAllByDate(tomorrow)
|
||||
rangeDesc = fmt.Sprintf("Tomorrow")
|
||||
rangeDesc = "Tomorrow"
|
||||
|
||||
case "upcoming":
|
||||
week := today.Add(7 * 24 * time.Hour)
|
||||
tasks, err = pane.taskRepo.GetAllByDateRange(today, week)
|
||||
rangeDesc = fmt.Sprintf("Upcoming (next 7 days)")
|
||||
rangeDesc = "Upcoming (next 7 days)"
|
||||
|
||||
case "unscheduled":
|
||||
tasks, err = pane.taskRepo.GetAllByDate(zeroTime)
|
||||
rangeDesc = fmt.Sprintf("Unscheduled (task with no due date) ")
|
||||
rangeDesc = "Unscheduled (task with no due date) "
|
||||
}
|
||||
|
||||
projectPane.activeProject = nil
|
||||
|
||||
29
app/util.go
29
app/util.go
@@ -67,10 +67,11 @@ func ignoreKeyEvt() bool {
|
||||
}
|
||||
|
||||
// yetToImplement - to use as callback for unimplemented features
|
||||
func yetToImplement(feature string) func() {
|
||||
message := fmt.Sprintf("[yellow]%s is yet to implement. Please Check in next version.", feature)
|
||||
return func() { statusBar.showForSeconds(message, 5) }
|
||||
}
|
||||
// `yetToImplement` is unused (deadcode)
|
||||
// func yetToImplement(feature string) func() {
|
||||
// message := fmt.Sprintf("[yellow]%s is yet to implement. Please Check in next version.", feature)
|
||||
// return func() { statusBar.showForSeconds(message, 5) }
|
||||
// }
|
||||
|
||||
func removeThirdCol() {
|
||||
contents.RemoveItem(taskDetailPane)
|
||||
@@ -83,8 +84,7 @@ func getTaskTitleColor(task model.Task) string {
|
||||
if task.Completed {
|
||||
colorName = "green"
|
||||
} else if task.DueDate != 0 {
|
||||
dayDiff := int(time.Unix(task.DueDate, 0).Sub(time.Now()).Hours() / 24)
|
||||
|
||||
dayDiff := int(time.Until(time.Unix(task.DueDate, 0)).Hours() / 24)
|
||||
if dayDiff == 0 {
|
||||
colorName = "orange"
|
||||
} else if dayDiff < 0 {
|
||||
@@ -111,12 +111,13 @@ func makeTaskListingTitle(task model.Task) string {
|
||||
return fmt.Sprintf("[%s]%s %s%s", getTaskTitleColor(task), checkbox, prefix, task.Title)
|
||||
}
|
||||
|
||||
func findProjectByID(id int64) *model.Project {
|
||||
for i := range projectPane.projects {
|
||||
if projectPane.projects[i].ID == id {
|
||||
return &projectPane.projects[i]
|
||||
}
|
||||
}
|
||||
// `findProjectByID` is unused (deadcode)
|
||||
// func findProjectByID(id int64) *model.Project {
|
||||
// for i := range projectPane.projects {
|
||||
// if projectPane.projects[i].ID == id {
|
||||
// return &projectPane.projects[i]
|
||||
// }
|
||||
// }
|
||||
|
||||
return nil
|
||||
}
|
||||
// return nil
|
||||
// }
|
||||
|
||||
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
|
||||
}
|
||||
34
build-windows.ps1
Normal file
34
build-windows.ps1
Normal file
@@ -0,0 +1,34 @@
|
||||
# Build script for Windows
|
||||
# This script builds Geek-life for different Windows architectures
|
||||
|
||||
Write-Host "Building Geek-life for Windows..." -ForegroundColor Green
|
||||
|
||||
# Create builds directory if it doesn't exist
|
||||
if (!(Test-Path "builds")) {
|
||||
New-Item -ItemType Directory -Path "builds"
|
||||
}
|
||||
|
||||
# Build for Windows AMD64 (64-bit)
|
||||
Write-Host "Building for Windows AMD64..." -ForegroundColor Yellow
|
||||
$env:GOOS="windows"
|
||||
$env:GOARCH="amd64"
|
||||
go build -ldflags="-s -w" -o builds/geek-life_windows-amd64.exe ./app
|
||||
|
||||
# Build for Windows 386 (32-bit)
|
||||
Write-Host "Building for Windows 386..." -ForegroundColor Yellow
|
||||
$env:GOOS="windows"
|
||||
$env:GOARCH="386"
|
||||
go build -ldflags="-s -w" -o builds/geek-life_windows-386.exe ./app
|
||||
|
||||
# Build for Windows ARM64
|
||||
Write-Host "Building for Windows ARM64..." -ForegroundColor Yellow
|
||||
$env:GOOS="windows"
|
||||
$env:GOARCH="arm64"
|
||||
go build -ldflags="-s -w" -o builds/geek-life_windows-arm64.exe ./app
|
||||
|
||||
Write-Host "Build completed!" -ForegroundColor Green
|
||||
Write-Host "Generated files:" -ForegroundColor Cyan
|
||||
Get-ChildItem builds/geek-life_windows*.exe | Select-Object Name, Length | Format-Table -AutoSize
|
||||
|
||||
Write-Host "`nTo run the application:" -ForegroundColor Yellow
|
||||
Write-Host " .\builds\geek-life_windows-amd64.exe" -ForegroundColor White
|
||||
8
build.sh
8
build.sh
@@ -1,9 +1,13 @@
|
||||
# go build -o geek-life ./app
|
||||
PS D:\Projects\geek-life\geek-life\builds> .\geek-life_windows-amd64.exe
|
||||
2025/09/16 00:50:54 Could not connect Embedded Database File: %!w(*fs.PathError=&{open C:\Users\othman.suseno\.geek-life\default.db 3})
|
||||
2025/09/16 00:50:54 FATAL ERROR: Exiting program! - Could not connect Embedded Database File
|
||||
PS D:\Projects\geek-life\geek-life\builds># go build -o geek-life ./app
|
||||
env GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w" -o builds/geek-life_darwin-amd64 ./app
|
||||
# env GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w" -o builds/geek-life_darwin-arm64 ./app
|
||||
env GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o builds/geek-life_linux-amd64 ./app
|
||||
env GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o builds/geek-life_linux-arm64 ./app
|
||||
env GOOS=windows GOARCH=386 go build -ldflags="-s -w" -o builds/geek-life_windows-386 ./app
|
||||
env GOOS=windows GOARCH=amd64 go build -ldflags="-s -w" -o builds/geek-life_windows-amd64.exe ./app
|
||||
env GOOS=windows GOARCH=386 go build -ldflags="-s -w" -o builds/geek-life_windows-386.exe ./app
|
||||
upx builds/geek-life_*
|
||||
|
||||
echo "SHA256 sum of release binaries: \n"
|
||||
|
||||
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.
|
||||
11
go.mod
11
go.mod
@@ -1,12 +1,12 @@
|
||||
module github.com/ajaxray/geek-life
|
||||
|
||||
go 1.14
|
||||
go 1.19
|
||||
|
||||
require (
|
||||
github.com/DataDog/zstd v1.4.5 // indirect
|
||||
github.com/Sereal/Sereal v0.0.0-20200820125258-a016b7cda3f3 // indirect
|
||||
github.com/asdine/storm/v3 v3.2.1
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/gdamore/tcell/v2 v2.1.0
|
||||
github.com/golang/protobuf v1.4.3 // indirect
|
||||
github.com/golang/snappy v0.0.2 // indirect
|
||||
@@ -15,6 +15,7 @@ require (
|
||||
github.com/mitchellh/go-homedir v1.1.0
|
||||
github.com/pgavlin/femto v0.0.0-20201224065653-0c9d20f9cac4
|
||||
github.com/rivo/tview v0.0.0-20210111184519-c818a0c789ee
|
||||
github.com/spf13/pflag v1.0.5
|
||||
github.com/stretchr/testify v1.7.0 // indirect
|
||||
go.etcd.io/bbolt v1.3.5 // indirect
|
||||
golang.org/x/net v0.0.0-20201224014010-6772e930b67b // indirect
|
||||
@@ -25,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
|
||||
)
|
||||
|
||||
21
go.sum
21
go.sum
@@ -1,16 +1,13 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/DataDog/zstd v1.4.1 h1:3oxKN3wbHibqx897utPC2LTQU4J+IHWWJO+glkAkpFM=
|
||||
github.com/DataDog/zstd v1.4.1/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
|
||||
github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
|
||||
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
|
||||
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863 h1:BRrxwOZBolJN4gIwvZMJY1tzqBvQgpaZiQRuIDD40jM=
|
||||
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM=
|
||||
github.com/Sereal/Sereal v0.0.0-20200820125258-a016b7cda3f3 h1:XgiXcABXIRyuLNyKHIk6gICrVXcGooDUxR+XMRr2QDM=
|
||||
github.com/Sereal/Sereal v0.0.0-20200820125258-a016b7cda3f3/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM=
|
||||
github.com/asdine/storm/v3 v3.2.1 h1:I5AqhkPK6nBZ/qJXySdI7ot5BlXSZ7qvDY1zAn5ZJac=
|
||||
github.com/asdine/storm/v3 v3.2.1/go.mod h1:LEpXwGt4pIqrE/XcTvCnZHT5MgZCV6Ub9q7yQzOFWr0=
|
||||
github.com/atotto/clipboard v0.1.2 h1:YZCtFu5Ie8qX2VmVTBnrqLSiU9XOWwqNRmdT3gIQzbY=
|
||||
github.com/atotto/clipboard v0.1.2/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
@@ -24,7 +21,6 @@ github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.m
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
|
||||
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
|
||||
github.com/gdamore/tcell/v2 v2.0.1-0.20201017141208-acf90d56d591 h1:0WWUDZ1oxq7NxVyGo8M3KI5jbkiwNAdZFFzAdC68up4=
|
||||
github.com/gdamore/tcell/v2 v2.0.1-0.20201017141208-acf90d56d591/go.mod h1:vSVL/GV5mCSlPC6thFP5kfOFdM9MGZcalipmpTxTgQA=
|
||||
github.com/gdamore/tcell/v2 v2.1.0 h1:UnSmozHgBkQi2PGsFr+rpdXuAPRRucMegpQp3Z3kDro=
|
||||
github.com/gdamore/tcell/v2 v2.1.0/go.mod h1:vSVL/GV5mCSlPC6thFP5kfOFdM9MGZcalipmpTxTgQA=
|
||||
@@ -41,24 +37,20 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
|
||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||
github.com/golang/protobuf v1.4.3 h1:JjCZWpVbqXDqFVmTfYWEVTMIYrL/NPdPSCHPJ0T/raM=
|
||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
|
||||
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v0.0.2 h1:aeE13tS0IiQgFjYdoL8qN3K1N2bXXtI6Vi51/y7BpMw=
|
||||
github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.4 h1:L8R9j+yAqZuZjsqh/z+F1NCffTKKLShY6zXTItVIZ8M=
|
||||
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
@@ -81,9 +73,10 @@ github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
||||
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
@@ -91,7 +84,6 @@ github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaU
|
||||
github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
|
||||
github.com/zyedidia/micro v1.4.1 h1:OuszISyaEPK/8xxkklkh7dp2ragvKDEnr4RyHfJcQdo=
|
||||
github.com/zyedidia/micro v1.4.1/go.mod h1:/wcvhlXPvvvb6v176yUQE4gNzr+Erwz4pWfx7PU/cuE=
|
||||
go.etcd.io/bbolt v1.3.4 h1:hi1bXHMVrlQh6WwxAy+qZCV/SYIlqo+Ushwdpa4tAKg=
|
||||
go.etcd.io/bbolt v1.3.4/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
|
||||
go.etcd.io/bbolt v1.3.5 h1:XAzx9gjCb0Rxj7EoqcClPD1d5ZBxZJk0jbuoPHenBt0=
|
||||
go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
|
||||
@@ -105,7 +97,6 @@ golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73r
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20191105084925-a882066a44e0 h1:QPlSTtPE2k6PZPasQUbzuK3p9JbS+vMXYVto8g/yrsg=
|
||||
golang.org/x/net v0.0.0-20191105084925-a882066a44e0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201224014010-6772e930b67b h1:iFwSg7t5GZmB/Q5TjiEAsdoLDrdJRC1RiF2WhuV29Qw=
|
||||
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
@@ -117,7 +108,6 @@ golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5h
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201017003518-b09fb700fbb7 h1:XtNJkfEjb4zR3q20BBBcYUykVOEMgZeIUOpBPfNYgxg=
|
||||
golang.org/x/sys v0.0.0-20201017003518-b09fb700fbb7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201214210602-f9fddec55a1e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -125,9 +115,7 @@ golang.org/x/sys v0.0.0-20210113131315-ba0562f347e0 h1:rTJ72jiMIolMfCaiZLkdlJLN2
|
||||
golang.org/x/sys v0.0.0-20210113131315-ba0562f347e0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.5 h1:i6eZZ+zk0SOf0xgBpEpPD18qWcJda6q1sxt3S0kzyUQ=
|
||||
@@ -137,13 +125,11 @@ golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGm
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
||||
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
@@ -159,13 +145,11 @@ google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQ
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.0 h1:4MY060fB1DLGMB/7MBTLnwQUY6+F09GEiz6SsrNqyzM=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.25.0 h1:Ejskq+SyPohKW+1uil0JJMtmHCgJPJ/qWTxr8qp+R4c=
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
@@ -173,7 +157,6 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
BIN
media/jetbrains.png
Normal file
BIN
media/jetbrains.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
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)
|
||||
}
|
||||
}
|
||||
24
util/util.go
24
util/util.go
@@ -5,7 +5,7 @@ import (
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -15,10 +15,22 @@ import (
|
||||
)
|
||||
|
||||
// ConnectStorm Create database connection
|
||||
func ConnectStorm() *storm.DB {
|
||||
dbPath := GetEnvStr("DB_FILE", "")
|
||||
var err error
|
||||
func ConnectStorm(dbFilePath string) *storm.DB {
|
||||
var dbPath string
|
||||
|
||||
if dbFilePath != "" {
|
||||
info, err := os.Stat(dbFilePath)
|
||||
if err == nil && info.IsDir() {
|
||||
fmt.Println("Mentioned DB path is a directory. Please specify a file or ignore to create automatically in home directory.")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
dbPath = dbFilePath
|
||||
} else {
|
||||
dbPath = GetEnvStr("DB_FILE", "")
|
||||
}
|
||||
|
||||
var err error
|
||||
if dbPath == "" {
|
||||
// Try in home dir
|
||||
dbPath, err = homedir.Expand("~/.geek-life/default.db")
|
||||
@@ -30,7 +42,7 @@ func ConnectStorm() *storm.DB {
|
||||
}
|
||||
}
|
||||
|
||||
CreateDirIfNotExist(path.Dir(dbPath))
|
||||
CreateDirIfNotExist(filepath.Dir(dbPath))
|
||||
|
||||
db, openErr := storm.Open(dbPath)
|
||||
FatalIfError(openErr, "Could not connect Embedded Database File")
|
||||
@@ -63,7 +75,7 @@ func UnixToTime(timestamp string) time.Time {
|
||||
func LogIfError(err error, msgOrPattern string, args ...interface{}) bool {
|
||||
if err != nil {
|
||||
message := fmt.Sprintf(msgOrPattern, args...)
|
||||
log.Printf("%s: %w\n", message, err)
|
||||
log.Printf("%s: %v\n", message, err)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user