Compare commits

12 Commits

Author SHA1 Message Date
aee884a586 Merge pull request 'build multi tenant' (#1) from main into master
Reviewed-on: #1
2025-10-12 08:55:42 +00:00
1fbb202002 build multi tenant 2025-10-12 15:47:00 +07:00
Othman Suseno
460934af65 Add Windows build support and fix Windows compatibility issues- Add build-windows.ps1 PowerShell script for Windows builds- Add BUILD-WINDOWS.md documentation for Windows users - Update build.sh to include Windows AMD64 and .exe extensions- Fix util/util.go Windows path handling (use filepath instead of path)- Fix error logging format (%v instead of %w)- Support Windows AMD64, 386, and ARM64 architectures 2025-09-16 01:15:52 +07:00
Phanupong Janthapoon
0dedb1a7d9 [FIX] Misspell (#46) 2023-12-10 01:08:00 +06:00
Anis uddin Ahmad
8ac35a2c81 Fixed markdown syntax issue in readme 2022-01-07 22:38:02 +06:00
Anis Ahmad
df53721939 Added note about JetBrains sponsorship in Readme 2022-01-07 22:35:18 +06:00
Anis Ahmad
ca0479b28a Some refactoring and fixing in confirmation popup implementation
- Restoring focus to previous (taskPane) panel after closing modal (works only when invoked with shortcut)
- `projectPane.RemoveActivateProject()` and `taskPane.ClearCompletedTasks()` do comply with `func()`. So no need to wrap within `func() {}`
- Fixed issue - Project dosen't exit with Ctrl+C once (maybe because of re-running `.Run()` in `AskYesNo()`).
- When focusing back to tasklist from task detail, re-showing projectDetail pane
2022-01-07 21:33:59 +06:00
Kuprijanov Roman
e466e2f801 Show confirmation popup for delete project and clear completed tasks (#35) 2022-01-07 19:01:52 +06:00
Kuprijanov Roman
e4132138ab fix showing horizontal line rune (#36) 2021-12-09 11:16:31 +06:00
Kuprijanov Roman
9233c89ab6 fix golangci-lint warning (#33) 2021-12-03 00:38:45 +06:00
Kuprijanov Roman
21defdafdc We give Shortcut Key to the lower case to reduce confusion. (#32) 2021-12-03 00:36:00 +06:00
Anis Ahmad
923f11be3e Added flag for setting DB file path 2021-10-16 19:17:57 +06:00
38 changed files with 2192 additions and 92 deletions

15
.env.example Normal file
View 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
View 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
View 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"]

View File

@@ -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.
---
[![JetBrains Logo](media/jetbrains.png)](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
View 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! 🚀

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

155
migration/migrator.go Normal file
View 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
}

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

View File

@@ -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"`
}

View File

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

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

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

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

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

View File

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

View File

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

View File

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