Browse Source

update core api

lblt 5 months ago
parent
commit
6dfe80f82c

+ 4 - 0
.gitignore

@@ -27,3 +27,7 @@ vendor/
 # Build output
 bin/
 dist/
+
+# Local test
+.trash/
+config.yaml

+ 111 - 45
README.md

@@ -1,54 +1,120 @@
-# byom-design
+# BYOM Core
+A Go-based backend service for workspace and user management with built-in authentication.
+## Features
 
-## Project Structure
+- Workspace Management
+- User Authentication (JWT)
+- Profile Management
+- Email Notifications (SMTP)
+- SQLite Database (GORM)
+- Webhook Integration
+- TLS Support
+- Graceful Shutdown
+
+## Prerequisites
+
+- Go 1.22.5 or higher
+- SQLite 3
 
+## Project Structure
 ```
 .
-├── app
-│   ├── server.go
-│   └── middleware.go
-├── common
-│   ├── models.go
-│   ├── errors.go
-│   └── utils.go
-├── config
-│   ├── config.go
-│   └── validator.go
-├── database
-│   ├── client.go
-│   └── migrations
-├── design
-│   ├── handlers.go
-│   ├── service.go
-│   ├── repository.go
-│   ├── models.go
-│   └── interfaces.go
-├── docs
-├── internal
-│   └── cache
-├── logger
-├── pkg
-│   ├── apiclient
-│   └── validator
-└── main.go
-```
-
-## Getting Started
-
-1. Install dependencies:
-   ```bash
-   go mod download
-   ```
-
-2. Run the application:
-   ```bash
-   go run main.go
-   ```
+├── app/                # Core application components
+│   ├── server.go      # HTTP server setup
+│   ├── routes.go      # API route definitions
+│   └── middleware.go  # HTTP middlewares
+├── common/            # Shared models and utilities
+├── config/            # Configuration management
+├── handlers/          # HTTP request handlers
+├── hook/             # Webhook integration
+├── jwtutils/         # JWT authentication
+├── smtp/             # Email service
+│   └── templates/    # Email templates
+├── store/            # Database operations
+└── main.go           # Application entry point
+```
+## Quick Start
+
+Clone the repository:
+```bash
+git clone git@git.linuxforward.com:byom/byom-core.git
+cd byom-core
+```
+Install dependencies:
+```bash
+go mod download
+```
+Copy and configure settings:
+```bash
+cp config.sample.yaml config.yaml
+```
+Run the server:
+```bash
+go run main.go serve
+```
 
 ## Configuration
+The application is configured via config.yaml. Key sections:
+```yaml
+server:
+listening_port: 8443
+tls:
+enabled: false
+database:
+path: "./data/app.db"
+log:
+level: "info"
+jwt:
+jwt_secret: "your-jwt-secret"
+smtp:
+host: "smtp.example.com"
+oauth2:
+client_id: "your-client-id"
+```
+See config.sample.yaml for a complete example.
+## Development Commands
+Run server:
+```bash
+go run main.go serve --config config.yaml
+```
+Show version:
+```bash
+go run main.go version
+```
+## API Routes
+All routes are prefixed with /api/v1/core
+
+- GET /health - Health check
+- POST /auth/login - User authentication
+- POST /workspaces/owner - Create workspace owner
+- PUT /workspaces/owner - Initialize workspace owner
+- POST /workspaces/invite - Invite users
+- GET /workspaces/invite/validate - Validate invitations
+
+Protected routes require JWT authentication via the Authorization header.
+
+## Contributing
+
+Fork the repository
+Create your feature branch
+Commit your changes
+Push to the branch
+Create a Pull Request
+
+## TODO
 
-Configuration is stored in `config.yaml`. 
+- Add support for multiple OAuth2 providers
+- Improve error handling and validation
+- Add API documentation using OpenAPI/Swagger
+- Implement audit logging
+- Add metrics and monitoring
+- Improve test coverage
+- Add support for caching
+- Implement request tracing
+- Add support for webhooks configuration
+- Improve security headers configuration
+- Add support for recovery
 
-## Development
 
-...
+## License
+Proprietary - All rights reserved

+ 0 - 0
app/middleware.go


+ 91 - 0
app/routes.go

@@ -0,0 +1,91 @@
+package app
+
+import (
+	"fmt"
+	"net/http"
+	"strings"
+
+	"git.linuxforward.com/byom/byom-core/handlers"
+	"git.linuxforward.com/byom/byom-core/jwtutils"
+	"github.com/gin-gonic/gin"
+)
+
+func addRoutes(
+	rtr *gin.Engine,
+	jwtSvc *jwtutils.Service,
+	userHandler *handlers.UserHandler,
+	workspaceHandler *handlers.WorkspaceHandler,
+	profileHandler *handlers.ProfileHandler,
+) {
+
+	//group routes behind /api/v1/core
+	coreRtr := rtr.Group("/api/v1/core")
+
+	// Health check
+	coreRtr.GET("/health", func(c *gin.Context) {
+		c.JSON(http.StatusOK, gin.H{"status": "ok"})
+	})
+
+	// Auth routes
+	coreRtr.POST("/auth/login", userHandler.Login)
+
+	// User init routes
+	coreRtr.POST("/workspaces/owner", userHandler.InitWorkspaceOwner)
+	coreRtr.PUT("/workspaces/owner", userHandler.CreateWorkspaceOwner)
+	coreRtr.POST("/workspaces/invite", userHandler.CreateInvitedUser)
+	coreRtr.GET("/workspaces/invite/validate", userHandler.ValidateInvitedUser)
+
+	// Logged in user routes
+	auth := coreRtr.Group("/")
+	auth.Use(authMiddleware(jwtSvc))
+
+	// User management
+	auth.POST("/users/invite", userHandler.InviteUser)
+	//add user to workspace
+	auth.POST("/users/workspaces", userHandler.AddUserToWorkspace)
+	// auth.GET("/users/invitations", userHandler.ListInvitations)
+	// auth.DELETE("/users/invitations/:id", userHandler.CancelInvitation)
+
+	// Workspace management
+	auth.POST("/workspaces", workspaceHandler.CreateWorkspace)
+
+	// Profile management
+	auth.GET("/profiles", profileHandler.GetProfiles)
+	auth.POST("/profiles", profileHandler.CreateProfile)
+	auth.GET("/profiles/:id", profileHandler.GetProfile)
+	auth.PUT("/profiles/:id", profileHandler.UpdateProfile)
+	auth.DELETE("/profiles/:id", profileHandler.DeleteProfile)
+	auth.GET("/profiles/workspaces", profileHandler.GetProfilesByWorkspace)
+
+	// User profile
+	auth.GET("/users/me", userHandler.GetCurrentUser)
+	auth.PUT("/users/me", userHandler.UpdateCurrentUser)
+}
+
+func authMiddleware(jwtSvc *jwtutils.Service) gin.HandlerFunc {
+	return func(c *gin.Context) {
+		authHeader := c.GetHeader("Authorization")
+
+		if authHeader == "" {
+			fmt.Println("No auth header")
+			c.AbortWithStatus(http.StatusUnauthorized)
+			return
+		}
+
+		// Get token part, using SplitAfter to preserve the delimiter
+		token := strings.TrimPrefix(authHeader, "Bearer ")
+		token = strings.TrimSpace(token)
+
+		// Validate token without any cleaning since we now know it's clean
+		claims, err := jwtSvc.ValidateToken(token)
+		if err != nil {
+			fmt.Println(err)
+			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
+			return
+		}
+
+		// Store claims in the context using the exported key from jwtutils
+		c.Set("claims", claims)
+		c.Next()
+	}
+}

+ 213 - 0
app/server.go

@@ -0,0 +1,213 @@
+package app
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"os"
+	"os/signal"
+	"strconv"
+	"strings"
+	"syscall"
+	"time"
+
+	"git.linuxforward.com/byom/byom-core/config"
+	"git.linuxforward.com/byom/byom-core/handlers"
+	"git.linuxforward.com/byom/byom-core/hook"
+	"git.linuxforward.com/byom/byom-core/jwtutils"
+	"git.linuxforward.com/byom/byom-core/smtp"
+	"git.linuxforward.com/byom/byom-core/store"
+	"github.com/gin-contrib/cors"
+	"github.com/gin-gonic/gin"
+	"github.com/sirupsen/logrus"
+	"gorm.io/driver/sqlite"
+	"gorm.io/gorm"
+)
+
+type App struct {
+	entry            *logrus.Entry
+	cnf              *config.Config
+	ctx              context.Context
+	cancel           context.CancelFunc
+	dataStore        *store.DataStore
+	emailSvc         *smtp.Service
+	jwtSvc           *jwtutils.Service
+	hookSvc          *hook.HookClient
+	userHandler      *handlers.UserHandler
+	workspaceHandler *handlers.WorkspaceHandler
+	profileHandler   *handlers.ProfileHandler
+	router           *gin.Engine
+}
+
+type ServiceHandler interface {
+	SetupRoutes(r *gin.Engine)
+	Close() error
+}
+
+func NewApp(cnf *config.Config) (*App, error) {
+	ctx, cancel := context.WithCancel(context.Background())
+
+	rtr := gin.New()
+	rtr.Use(gin.Recovery())
+	rtr.Use(gin.Logger())
+
+	config := cors.DefaultConfig()
+	config.AllowOrigins = []string{"*"}
+	config.AllowMethods = []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}
+	config.AllowHeaders = []string{"Origin", "Content-Type", "Accept", "Authorization"}
+	config.AllowCredentials = true
+	config.ExposeHeaders = []string{"Content-Length"}
+
+	rtr.Use(cors.New(config))
+
+	//init GORM
+	dbConn, err := initDBConn(cnf.Database)
+	if err != nil {
+		cancel()
+		return nil, fmt.Errorf("init db: %w", err)
+	}
+
+	//init email service
+	emailSvc := smtp.NewService(cnf.Smtp)
+
+	//init jwt service
+	jwtSvc := jwtutils.NewService(cnf.Jwt.JwtSecret)
+
+	//init hook service
+	hookSvc := hook.NewHookClient(cnf.Hook.BaseURL, cnf.Hook.Domain, cnf.Hook.SecretKey)
+
+	//init core functions
+	store := store.NewDataStore(dbConn)
+	userHandler := handlers.NewUserHandler(store, emailSvc, jwtSvc, hookSvc)
+	workspaceHandler := handlers.NewWorkspaceHandler(store)
+	profileHandler := handlers.NewProfileHandler(store)
+
+	//add routes
+	addRoutes(rtr, jwtSvc, userHandler, workspaceHandler, profileHandler)
+
+	app := &App{
+		entry:            logrus.WithField("component", "app"),
+		cnf:              cnf,
+		ctx:              ctx,
+		cancel:           cancel,
+		router:           rtr,
+		dataStore:        store,
+		emailSvc:         emailSvc,
+		jwtSvc:           jwtSvc,
+		userHandler:      userHandler,
+		workspaceHandler: workspaceHandler,
+		profileHandler:   profileHandler,
+	}
+
+	return app, nil
+}
+
+func (a *App) Run() error {
+	a.entry.Info("Starting server...")
+
+	if len(a.cnf.Server.Opts) > 0 {
+		a.router.Use(func(c *gin.Context) {
+			for _, opt := range a.cnf.Server.Opts {
+				parts := strings.Split(opt, ": ")
+				if len(parts) == 2 {
+					c.Header(parts[0], parts[1])
+				}
+			}
+			c.Next()
+		})
+	}
+
+	// Configure server
+	srv := &http.Server{
+		Addr:    fmt.Sprintf(":%s", strconv.Itoa(a.cnf.Server.ListeningPort)),
+		Handler: a.router,
+	}
+
+	// Start server
+	go func() {
+		var err error
+
+		if a.cnf.Server.TlsConfig.Enabled {
+			a.entry.Infof("Starting server on port %d with TLS", a.cnf.Server.ListeningPort)
+			err = srv.ListenAndServeTLS(
+				a.cnf.Server.TlsConfig.CertFile,
+				a.cnf.Server.TlsConfig.KeyFile,
+			)
+
+		} else {
+			a.entry.Infof("Starting server on port %d without TLS", a.cnf.Server.ListeningPort)
+			err = srv.ListenAndServe()
+		}
+
+		if err != nil && err != http.ErrServerClosed {
+			a.entry.WithError(err).Error("Server error")
+		}
+	}()
+
+	// Graceful shutdown
+	quit := make(chan os.Signal, 1)
+	signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
+	<-quit
+
+	a.entry.Info("Stopping server...")
+	ctxTimeout, cancelFunc := context.WithTimeout(context.Background(), 30*time.Second)
+	defer cancelFunc()
+	err := srv.Shutdown(ctxTimeout)
+	if err != nil {
+		return fmt.Errorf("shutdown server: %w", err)
+	}
+
+	// Close all services
+	if err := a.Close(); err != nil {
+		a.entry.WithError(err).Error("Error during service cleanup")
+	}
+
+	a.entry.Info("Server stopped successfully")
+	return nil
+}
+
+func initDBConn(cnf *config.Database) (*gorm.DB, error) {
+
+	db, err := gorm.Open(sqlite.Open(cnf.Path), &gorm.Config{})
+	if err != nil {
+		return nil, fmt.Errorf("database: %w", err)
+	}
+
+	//Tune the DB
+	sqlDB, err := db.DB()
+	if err != nil {
+		return nil, fmt.Errorf("database: %w", err)
+	}
+
+	sqlDB.SetMaxOpenConns(cnf.MaxOpenConns)
+	sqlDB.SetMaxIdleConns(cnf.MaxIdleConns)
+	sqlDB.SetConnMaxLifetime(time.Duration(cnf.ConnMaxLifetime) * time.Second)
+
+	for _, pragma := range cnf.Pragma {
+		tx := db.Exec("PRAGMA " + pragma)
+		if tx.Error != nil {
+			return nil, fmt.Errorf("database: %w", tx.Error)
+		}
+	}
+
+	return db, nil
+}
+
+func (a *App) Close() error {
+	a.entry.Info("Closing application resources...")
+
+	if err := a.dataStore.Close(); err != nil {
+		a.entry.WithError(err).Error("Failed to close database connections")
+		return err
+	}
+
+	if err := a.emailSvc.Close(); err != nil {
+		a.entry.WithError(err).Error("Failed to close email service")
+	}
+
+	if err := a.hookSvc.Close(); err != nil {
+		a.entry.WithError(err).Error("Failed to close hook service")
+	}
+
+	return nil
+}

+ 0 - 0
common/errors.go


+ 10 - 0
common/models.go

@@ -0,0 +1,10 @@
+package common
+
+type LoginRequest struct {
+	Email    string `json:"email"`
+	Password string `json:"password"`
+}
+
+type LoginResponse struct {
+	Token string `json:"token"`
+}

+ 25 - 0
common/profile_model.go

@@ -0,0 +1,25 @@
+package common
+
+import "github.com/google/uuid"
+
+type Profile struct {
+	ID          uuid.UUID `json:"id"`
+	Name        string    `json:"name"`
+	WorkspaceID uuid.UUID `json:"workspace_id"`
+}
+
+type Social struct {
+	ID        uuid.UUID `json:"id"`
+	ProfileID uuid.UUID `json:"profile_id"`
+	Platform  string    `json:"platform"`
+	Handle    string    `json:"handle"`
+}
+
+type CreateProfileRequest struct {
+	Name        string `json:"name"`
+	WorkspaceID string `json:"workspace_id"`
+}
+
+type UpdateProfileRequest struct {
+	Name string `json:"name"`
+}

+ 90 - 0
common/user_model.go

@@ -0,0 +1,90 @@
+package common
+
+import (
+	"time"
+
+	"github.com/google/uuid"
+)
+
+type User struct {
+	ID                uuid.UUID `gorm:"type:uuid;primary_key" json:"id"`
+	Email             string    `gorm:"unique;size:255" json:"email"`
+	Name              string    `gorm:"size:255" json:"name"`
+	PhoneNumber       string    `gorm:"size:50" json:"phone_number"`
+	Password          string    `gorm:"size:255" json:"-"`
+	PasswordCreated   bool      `gorm:"default:false" json:"password_created"`
+	PasswordCreatedAt time.Time
+	Role              string    `gorm:"size:50;default:member"`
+	Status            string    `gorm:"size:50;default:active"`
+	InvitedBy         uuid.UUID `gorm:"type:uuid"`
+	CreatedAt         time.Time
+	UpdatedAt         time.Time
+	Workspaces        []Workspace `gorm:"many2many:user_workspace_roles;"`
+}
+
+type UserMe struct {
+	User       User        `json:"user"`
+	Workspaces []Workspace `json:"workspaces"`
+}
+
+// Invite represents an invitation to join the system.
+type Invite struct {
+	ID        uuid.UUID `gorm:"type:uuid;primary_key" json:"id"`
+	Email     string    `gorm:"size:255" json:"email"`
+	Workspace string    `gorm:"size:255" json:"workspace_id"`
+	Role      string    `gorm:"size:50;default:member" json:"role"`
+	Token     string    `gorm:"unique;size:255" json:"token"`
+	Status    string    `gorm:"size:50;default:pending" json:"status"`
+	ExpiresAt time.Time `json:"expires_at"`
+	CreatedAt time.Time `json:"created_at"`
+	UpdatedAt time.Time `json:"updated_at"`
+}
+
+type UserWorkspaceRole struct {
+	UserID      uuid.UUID `gorm:"primaryKey;type:uuid"`
+	WorkspaceID uuid.UUID `gorm:"primaryKey;type:uuid"`
+	Role        string    `gorm:"size:50;default:member"`
+	CreatedAt   time.Time
+	UpdatedAt   time.Time
+
+	// Optional: Add these if you want GORM to handle the relationships
+	User      User      `gorm:"foreignKey:UserID"`
+	Workspace Workspace `gorm:"foreignKey:WorkspaceID"`
+}
+
+type CreateOwnerResponse struct {
+	User  User   `json:"user"`
+	Token string `json:"token"`
+}
+
+type InitWorkspaceOwnerRequest struct {
+	Email string `json:"email"`
+	Name  string `json:"name"`
+	Phone string `json:"phone_number"`
+}
+
+type CreateInvitedUserRequest struct {
+	Email       string `json:"email"`
+	Name        string `json:"name"`
+	PhoneNumber string `json:"phone_number"`
+	Password    string `json:"password"`
+	Token       string `json:"token"`
+}
+
+type CreateInvitedUserResponse struct {
+	User     User   `json:"user"`
+	Worspace string `json:"workspace_id"`
+}
+
+type InviteUserRequest struct {
+	Email     string `json:"email"`
+	Workspace string `json:"workspace_id"`
+	Role      string `json:"role"`
+}
+
+type ValidateInvitedUserRequest struct {
+	Valid       bool   `json:"valid"`
+	WorkspaceID string `json:"workspace_id"`
+	Email       string `json:"email"`
+	Error       string `json:"error"`
+}

+ 0 - 0
common/utils.go


+ 28 - 0
common/workspace_model.go

@@ -0,0 +1,28 @@
+package common
+
+import (
+	"time"
+
+	"github.com/google/uuid"
+	"gorm.io/gorm"
+)
+
+type Workspace struct {
+	ID        uuid.UUID      `gorm:"type:uuid;primary_key" json:"id"`
+	Name      string         `gorm:"not null;size:100" json:"name"`
+	CreatedAt time.Time      `json:"created_at"`
+	UpdatedAt time.Time      `json:"updated_at"`
+	DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"`
+}
+
+type CreateWorkspaceOwnerRequest struct {
+	Email       string `json:"email"`
+	Name        string `json:"name"`
+	PhoneNumber string `json:"phone_number"`
+	Password    string `json:"password"`
+}
+
+type AddUserToWorkspaceRequest struct {
+	WorkspaceID uuid.UUID `json:"workspace_id"`
+	Role        string    `json:"role"`
+}

+ 54 - 0
config.sample.yaml

@@ -0,0 +1,54 @@
+server:
+  listening_port: 8443
+  tls:
+    enabled: false
+    cert: "/path/to/cert.pem"
+    key: "/path/to/key.pem"
+  opts:
+    - "Access-Control-Allow-Origin: *"
+    - "Access-Control-Allow-Methods: GET, POST, PUT, DELETE"
+    - "Access-Control-Allow-Headers: Content-Type, Authorization"
+
+database:
+  path: "./data/app.db"
+  log_mode: true
+  max_open_conns: 10
+  max_idle_conns: 5
+  conn_max_lifetime: 300
+  pragma:
+    - "journal_mode=WAL"
+    - "busy_timeout=5000"
+    - "synchronous=NORMAL"
+
+log:
+  level: "info"  # trace, debug, info, warn, error, fatal, panic
+  no_color: false
+  force_colors: true
+  in_json: false
+  filter_components:
+    - "api"
+    - "db"
+    - "auth"
+    - "email"
+
+jwt:
+  jwt_secret: "your-super-secret-jwt-key-change-this-in-production"
+
+smtp:
+  host: "smtp.gmail.com"
+  port: 587
+  user: "your-email@gmail.com"
+  pass: "your-app-specific-password"
+
+oauth2:
+  client_id: "your-oauth-client-id"
+  client_secret: "your-oauth-client-secret"
+  redirect_url: "http://localhost:8443/oauth/callback"
+  auth_url: "https://accounts.google.com/o/oauth2/auth"
+  token_url: "https://oauth2.googleapis.com/token"
+  user_info_url: "https://www.googleapis.com/oauth2/v3/userinfo"
+
+hook:
+  base_url: "https://api.yourwebhookservice.com"
+  domain: "yourdomain.com"
+  secret_key: "your-webhook-secret-key"

+ 235 - 0
config/config.go

@@ -0,0 +1,235 @@
+package config
+
+import (
+	"fmt"
+	"os"
+
+	log "github.com/sirupsen/logrus"
+	"gopkg.in/yaml.v3"
+)
+
+type Config struct {
+	Server   *Server   `yaml:"server"`
+	Database *Database `yaml:"database"`
+	Log      *Log      `yaml:"log"`
+	Jwt      *Jwt      `yaml:"jwt"`
+	Smtp     *Smtp     `yaml:"smtp"`
+	Oauth2   *Oauth2   `yaml:"oauth2"`
+	Hook     *Hook     `yaml:"hook"`
+}
+
+func (c *Config) UnmarshalYAML(unmarshal func(interface{}) error) error {
+	type plain Config
+	err := unmarshal((*plain)(c))
+	if err != nil {
+		return err
+	}
+	if c.Server == nil {
+		return fmt.Errorf("api config is required")
+	}
+	return nil
+}
+
+type Server struct {
+	ListeningPort int        `yaml:"listening_port"`
+	TlsConfig     *TlsConfig `yaml:"tls"`
+	Opts          []string   `yaml:"opts"`
+}
+
+func (c *Server) UnmarshalYAML(unmarshal func(interface{}) error) error {
+	type plain Server
+	err := unmarshal((*plain)(c))
+	if err != nil {
+		return err
+	}
+	if c.ListeningPort == 0 {
+		c.ListeningPort = 8443
+	}
+	if c.TlsConfig == nil {
+		return fmt.Errorf("tls is required")
+	}
+	return nil
+}
+
+type TlsConfig struct {
+	Enabled  bool   `yaml:"enabled"`
+	CertFile string `yaml:"cert"`
+	KeyFile  string `yaml:"key"`
+}
+
+func (c *TlsConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
+	type plain TlsConfig
+	err := unmarshal((*plain)(c))
+	if err != nil {
+		return err
+	}
+	if c.CertFile == "" && c.Enabled {
+		return fmt.Errorf("tls cert is required")
+	}
+	if c.KeyFile == "" && c.Enabled {
+		return fmt.Errorf("tls key is required")
+	}
+	return nil
+}
+
+type Smtp struct {
+	Host string `yaml:"host"`
+	Port int    `yaml:"port"`
+	User string `yaml:"user"`
+	Pass string `yaml:"pass"`
+}
+
+func (c *Smtp) UnmarshalYAML(unmarshal func(interface{}) error) error {
+	type plain Smtp
+	err := unmarshal((*plain)(c))
+	if err != nil {
+		return err
+	}
+	// if c.Host == "" {
+	// 	return fmt.Errorf("smtp host is required")
+	// }
+	// if c.Port == 0 {
+	// 	c.Port = 587
+	// }
+	// if c.User == "" {
+	// 	return fmt.Errorf("smtp user is required")
+	// }
+	// if c.Pass == "" {
+	// 	return fmt.Errorf("smtp pass is required")
+	// }
+	return nil
+}
+
+type Database struct {
+	Path            string   `yaml:"path"`
+	LogMode         bool     `yaml:"log_mode"`
+	MaxOpenConns    int      `yaml:"max_open_conns"`
+	MaxIdleConns    int      `yaml:"max_idle_conns"`
+	ConnMaxLifetime int      `yaml:"conn_max_lifetime"`
+	Pragma          []string `yaml:"pragma"`
+}
+
+func (c *Database) UnmarshalYAML(unmarshal func(interface{}) error) error {
+	type plain Database
+	err := unmarshal((*plain)(c))
+	if err != nil {
+		return err
+	}
+	if c.Path == "" {
+		return fmt.Errorf("database path is required")
+	}
+	if c.MaxOpenConns == 0 {
+		c.MaxOpenConns = 10
+	}
+	if c.MaxIdleConns == 0 {
+		c.MaxIdleConns = 5
+	}
+	if c.ConnMaxLifetime == 0 {
+		c.ConnMaxLifetime = 300
+	}
+	return nil
+}
+
+type Jwt struct {
+	JwtSecret string `yaml:"jwt_secret"`
+}
+
+func (c *Jwt) UnmarshalYAML(unmarshal func(interface{}) error) error {
+	type plain Jwt
+	err := unmarshal((*plain)(c))
+	if err != nil {
+		return err
+	}
+	if c.JwtSecret == "" {
+		return fmt.Errorf("jwt secret is required")
+	}
+	return nil
+}
+
+type Oauth2 struct {
+	ClientID     string `yaml:"client_id"`
+	ClientSecret string `yaml:"client_secret"`
+	RedirectURL  string `yaml:"redirect_url"`
+	AuthURL      string `yaml:"auth_url"`
+	TokenURL     string `yaml:"token_url"`
+	UserInfoURL  string `yaml:"user_info_url"`
+}
+
+func (c *Oauth2) UnmarshalYAML(unmarshal func(interface{}) error) error {
+	type plain Oauth2
+	err := unmarshal((*plain)(c))
+	if err != nil {
+		return err
+	}
+	if c.ClientID == "" {
+		return fmt.Errorf("client id is required")
+	}
+	if c.ClientSecret == "" {
+		return fmt.Errorf("client secret is required")
+	}
+	if c.RedirectURL == "" {
+		return fmt.Errorf("redirect url is required")
+	}
+	if c.AuthURL == "" {
+		return fmt.Errorf("auth url is required")
+	}
+	if c.TokenURL == "" {
+		return fmt.Errorf("token url is required")
+	}
+	if c.UserInfoURL == "" {
+		return fmt.Errorf("user info url is required")
+	}
+	return nil
+}
+
+type Log struct {
+	Level            string   `yaml:"level"`
+	NoColor          bool     `yaml:"no_color"`
+	ForceColors      bool     `yaml:"force_colors"`
+	InJson           bool     `yaml:"in_json"`
+	FilterComponents []string `yaml:"filter_components"`
+}
+
+func (c *Log) UnmarshalYAML(unmarshal func(interface{}) error) error {
+	type plain Log
+	err := unmarshal((*plain)(c))
+	if err != nil {
+		return err
+	}
+	log.SetFormatter(&log.TextFormatter{
+		ForceColors:   c.ForceColors,
+		DisableColors: c.NoColor,
+		FullTimestamp: true,
+	})
+	if c.Level != "" {
+		lvl, err := log.ParseLevel(c.Level)
+		if err != nil {
+			return err
+		}
+		log.SetLevel(lvl)
+	}
+	if c.InJson {
+		log.SetFormatter(&log.JSONFormatter{})
+	}
+
+	return nil
+}
+
+type Hook struct {
+	BaseURL   string `yaml:"base_url"`
+	Domain    string `yaml:"domain"`
+	SecretKey string `yaml:"secret_key"`
+}
+
+func ReadConfig(configPath string) (*Config, error) {
+	cnf := &Config{}
+	b, err := os.ReadFile(configPath)
+	if err != nil {
+		return nil, err
+	}
+	err = yaml.Unmarshal(b, cnf)
+	if err != nil {
+		return nil, err
+	}
+	return cnf, nil
+}

+ 0 - 0
config/validator.go


+ 0 - 0
database/client.go


+ 0 - 0
database/migrations/init.sql


+ 0 - 0
design/handlers.go


+ 0 - 0
design/interfaces.go


+ 0 - 0
design/models.go


+ 0 - 0
design/repository.go


+ 0 - 0
design/service.go


+ 330 - 0
docs/api.md

@@ -0,0 +1,330 @@
+
+
+# API Documentation
+
+## Database Schema
+
+```mermaid
+erDiagram
+    Vitrine {
+        string hostname PK
+        boolean used
+    }
+
+    VPS {
+        string id PK
+        string size
+        string ip
+        string version
+        string plan
+        string state
+        string hostname FK
+    }
+
+    Workspace {
+        string id PK
+        string name
+        string plan
+        timestamp created_at
+    }
+
+    User {
+        string id PK
+        string username
+        string email
+        string password_hash
+        boolean is_active
+        timestamp created_at
+    }
+
+    Profile {
+        string id PK
+        string profile_name
+        string workspace_id FK
+        timestamp created_at
+    }
+
+    Account {
+        string id PK
+        string type
+        string identifier
+        string profile_id FK
+        timestamp last_sync
+    }
+
+    AccountTrend {
+        string id PK
+        string account_id FK
+        string trend_name
+        float engagement_rate
+        int followers_growth
+        timestamp detected_at
+        timestamp expires_at
+    }
+
+    Suggestion {
+        string id PK
+        string profile_id FK
+        string type
+        string title
+        string description
+        float relevance_score
+        string status
+        timestamp created_at
+        timestamp updated_at
+    }
+
+    GeneratedImage {
+        string id PK
+        string suggestion_id FK
+        string image_url
+        string prompt_used
+        timestamp generated_at
+        boolean is_selected
+    }
+
+    WorkspaceUser {
+        string workspace_id FK
+        string user_id FK
+        string role
+        timestamp joined_at
+    }
+
+    VitrineUsers {
+        string hostname FK
+        string user_id FK
+    }
+
+    Vitrine ||--o{ VitrineUsers : "has"
+    Vitrine ||--o{ VPS : "hosts"
+    Workspace ||--o{ WorkspaceUser : "has"
+    User ||--o{ WorkspaceUser : "belongs_to"
+    Workspace ||--o{ Profile : "contains"
+    Profile ||--o{ Account : "has"
+    Account ||--o{ AccountTrend : "trends"
+    Profile ||--o{ Suggestion : "receives"
+    Suggestion ||--o{ GeneratedImage : "has"
+```
+
+## Authentication
+All protected routes require a Bearer token in the Authorization header:
+```
+Authorization: Bearer <token>
+```
+
+## Base URLs
+- Main platform: `https://localhost:8443/`
+- Workspace specific: `https://{workspace-name}.yourdomain.com/api`
+
+## Routes
+
+### Create Workspace Owner
+Creates the first user (owner) for a new workspace.
+
+```http
+POST /workspace/owner
+```
+
+**Request Body:**
+```json
+{
+    "email": "string",
+    "name": "string",
+    "password": "string",
+    "phone_number": "string"
+}
+```
+
+**Response:** `201 Created`
+```json
+{
+    "id": "uuid",
+    "email": "string",
+    "name": "string",
+    "phone_number": "string",
+    "role": "owner",
+    "status": "active"
+}
+```
+
+### Accept Invitation
+Creates a new user account from an invitation.
+
+```http
+POST /workspace/invite
+```
+
+**Request Body:**
+```json
+{
+    "email": "string",
+    "name": "string",
+    "password": "string",
+    "phone_number": "string",
+    "token": "string"
+}
+```
+
+**Response:** `201 Created`
+```json
+{
+    "id": "uuid",
+    "email": "string",
+    "name": "string",
+    "phone_number": "string",
+    "role": "string",
+    "status": "active"
+}
+```
+
+### Invite User
+Protected route to invite a new user to the workspace.
+
+```http
+POST /users/invite
+```
+
+**Request Body:**
+```json
+{
+    "email": "string",
+    "role": "string"
+}
+```
+
+**Response:** `201 Created`
+```json
+{
+    "id": "uuid",
+    "email": "string",
+    "role": "string",
+    "token": "string",
+    "expires_at": "timestamp"
+}
+```
+
+### List Invitations
+Protected route to get all pending invitations.
+
+```http
+GET /users/invitations
+```
+
+**Response:** `200 OK`
+```json
+{
+    "invitations": [
+        {
+            "id": "uuid",
+            "email": "string",
+            "role": "string",
+            "status": "string",
+            "expires_at": "timestamp",
+            "created_at": "timestamp"
+        }
+    ]
+}
+```
+
+### Cancel Invitation
+Protected route to cancel a pending invitation.
+
+```http
+DELETE /users/invitations/:id
+```
+
+**Response:** `200 OK`
+```json
+{
+    "success": true
+}
+```
+
+### Get Current User Profile
+Protected route to get the current user's profile.
+
+```http
+GET /users/me
+```
+
+**Response:** `200 OK`
+```json
+{
+    "id": "uuid",
+    "email": "string",
+    "name": "string",
+    "phone_number": "string",
+    "role": "string",
+    "status": "string"
+}
+```
+
+### Update Current User Profile
+Protected route to update the current user's profile.
+
+```http
+PUT /users/me
+```
+
+**Request Body:**
+```json
+{
+    "name": "string",
+    "phone_number": "string"
+}
+```
+
+**Response:** `200 OK`
+```json
+{
+    "id": "uuid",
+    "email": "string",
+    "name": "string",
+    "phone_number": "string",
+    "role": "string",
+    "status": "string"
+}
+```
+
+## Data Types
+
+### Role Values
+- `owner`: Workspace owner with full permissions
+- `admin`: Administrative user with elevated permissions
+- `member`: Regular workspace member
+
+### Status Values
+- `active`: User account is active
+- `inactive`: User account is temporarily inactive
+- `suspended`: User account has been suspended
+
+### Invitation Status Values
+- `pending`: Invitation is waiting to be accepted
+- `accepted`: Invitation has been accepted
+- `expired`: Invitation has expired
+- `cancelled`: Invitation was cancelled
+
+### Notes
+- All timestamps are in ISO 8601 format
+- All IDs are UUIDs
+- Workspace is identified by subdomain
+- Password requirements:
+  - Minimum 8 characters
+  - Must contain at least one number
+  - Must contain at least one uppercase letter
+  - Must contain at least one special character
+
+## Error Responses
+All error responses follow this format:
+```json
+{
+    "error": "string"
+}
+```
+
+Common HTTP status codes:
+- `400`: Bad Request - Invalid input data
+- `401`: Unauthorized - Missing or invalid authentication
+- `403`: Forbidden - Insufficient permissions
+- `404`: Not Found - Resource doesn't exist
+- `500`: Internal Server Error - Server-side error

+ 48 - 2
go.mod

@@ -1,3 +1,49 @@
-module git.linuxforward.com/byom/byom-design
+module git.linuxforward.com/byom/byom-core
 
-go 1.23.0
+go 1.22.5
+
+require (
+	github.com/alecthomas/kong v1.6.0
+	github.com/sirupsen/logrus v1.9.3
+)
+
+require (
+	github.com/bytedance/sonic v1.12.6 // indirect
+	github.com/bytedance/sonic/loader v0.2.1 // indirect
+	github.com/cloudwego/base64x v0.1.4 // indirect
+	github.com/cloudwego/iasm v0.2.0 // indirect
+	github.com/gabriel-vasile/mimetype v1.4.7 // indirect
+	github.com/gin-contrib/sse v0.1.0 // indirect
+	github.com/go-playground/locales v0.14.1 // indirect
+	github.com/go-playground/universal-translator v0.18.1 // indirect
+	github.com/go-playground/validator/v10 v10.23.0 // indirect
+	github.com/goccy/go-json v0.10.4 // indirect
+	github.com/jinzhu/inflection v1.0.0 // indirect
+	github.com/jinzhu/now v1.1.5 // indirect
+	github.com/json-iterator/go v1.1.12 // indirect
+	github.com/klauspost/cpuid/v2 v2.2.9 // indirect
+	github.com/leodido/go-urn v1.4.0 // indirect
+	github.com/mattn/go-isatty v0.0.20 // indirect
+	github.com/mattn/go-sqlite3 v1.14.22 // indirect
+	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+	github.com/modern-go/reflect2 v1.0.2 // indirect
+	github.com/pelletier/go-toml/v2 v2.2.3 // indirect
+	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
+	github.com/ugorji/go/codec v1.2.12 // indirect
+	golang.org/x/arch v0.12.0 // indirect
+	golang.org/x/crypto v0.31.0 // indirect
+	golang.org/x/net v0.33.0 // indirect
+	golang.org/x/text v0.21.0 // indirect
+	google.golang.org/protobuf v1.36.1 // indirect
+)
+
+require (
+	github.com/gin-contrib/cors v1.7.3
+	github.com/gin-gonic/gin v1.10.0
+	github.com/golang-jwt/jwt/v5 v5.2.1
+	github.com/google/uuid v1.6.0
+	golang.org/x/sys v0.28.0 // indirect
+	gopkg.in/yaml.v3 v3.0.1
+	gorm.io/driver/sqlite v1.5.7
+	gorm.io/gorm v1.25.12
+)

+ 127 - 0
go.sum

@@ -0,0 +1,127 @@
+github.com/alecthomas/kong v1.6.0 h1:mwOzbdMR7uv2vul9J0FU3GYxE7ls/iX1ieMg5WIM6gE=
+github.com/alecthomas/kong v1.6.0/go.mod h1:p2vqieVMeTAnaC83txKtXe8FLke2X07aruPWXyMPQrU=
+github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
+github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
+github.com/bytedance/sonic v1.12.6 h1:/isNmCUF2x3Sh8RAp/4mh4ZGkcFAX/hLrzrK3AvpRzk=
+github.com/bytedance/sonic v1.12.6/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
+github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
+github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
+github.com/bytedance/sonic/loader v0.2.1 h1:1GgorWTqf12TA8mma4DDSbaQigE2wOgQo7iCjjJv3+E=
+github.com/bytedance/sonic/loader v0.2.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
+github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
+github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
+github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
+github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
+github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
+github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA=
+github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU=
+github.com/gin-contrib/cors v1.7.3 h1:hV+a5xp8hwJoTw7OY+a70FsL8JkVVFTXw9EcfrYUdns=
+github.com/gin-contrib/cors v1.7.3/go.mod h1:M3bcKZhxzsvI+rlRSkkxHyljJt1ESd93COUvemZ79j4=
+github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
+github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
+github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
+github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
+github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
+github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
+github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
+github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
+github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
+github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
+github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o=
+github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
+github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
+github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
+github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
+github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
+github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
+github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
+github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
+github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
+github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
+github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
+github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
+github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
+github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
+github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
+github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
+github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
+github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
+github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
+github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
+github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
+github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
+github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
+golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
+golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
+golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
+golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg=
+golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
+golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
+golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
+golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
+golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
+golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
+golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
+golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
+golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
+golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
+golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
+golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
+golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
+google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
+google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
+google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
+google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
+gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
+gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
+gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
+nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
+rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

+ 136 - 0
handlers/profile.go

@@ -0,0 +1,136 @@
+package handlers
+
+import (
+	"git.linuxforward.com/byom/byom-core/common"
+	"git.linuxforward.com/byom/byom-core/store"
+	"github.com/gin-gonic/gin"
+	"github.com/google/uuid"
+	"github.com/sirupsen/logrus"
+)
+
+type ProfileHandler struct {
+	logger *logrus.Entry
+	store  *store.DataStore
+}
+
+func NewProfileHandler(db *store.DataStore) *ProfileHandler {
+	logger := logrus.WithField("core", "profile-handler")
+	return &ProfileHandler{
+		logger: logger,
+		store:  db,
+	}
+}
+
+func (h *ProfileHandler) GetProfiles(c *gin.Context) {
+	profiles, err := h.store.GetProfiles(c)
+	if err != nil {
+		c.JSON(500, gin.H{"error": "Failed to get profiles"})
+		return
+	}
+
+	c.JSON(200, profiles)
+}
+
+func (h *ProfileHandler) CreateProfile(c *gin.Context) {
+	var req common.CreateProfileRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(400, gin.H{"error": "Invalid request"})
+		return
+	}
+
+	h.logger.Info("Creating profile")
+	h.logger.Info(req)
+
+	profile := &common.Profile{
+		ID:          uuid.New(),
+		Name:        req.Name,
+		WorkspaceID: uuid.MustParse(req.WorkspaceID),
+	}
+
+	if err := h.store.CreateProfile(c, profile); err != nil {
+		c.JSON(500, gin.H{"error": "Failed to create profile"})
+		return
+	}
+
+	c.JSON(201, profile)
+
+}
+
+func (h *ProfileHandler) GetProfile(c *gin.Context) {
+	id := c.Param("id")
+	if id == "" {
+		c.JSON(400, gin.H{"error": "profile id is required"})
+		return
+	}
+
+	profile, err := h.store.GetProfile(c, id)
+	if err != nil {
+		c.JSON(500, gin.H{"error": "Failed to get profile"})
+		return
+	}
+
+	c.JSON(200, profile)
+
+}
+
+func (h *ProfileHandler) UpdateProfile(c *gin.Context) {
+	var req common.UpdateProfileRequest
+
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(400, gin.H{"error": "Invalid request"})
+		return
+	}
+
+	id := c.Param("id")
+	if id == "" {
+		c.JSON(400, gin.H{"error": "profile id is required"})
+		return
+	}
+
+	profile, err := h.store.GetProfile(c, id)
+	if err != nil {
+		c.JSON(500, gin.H{"error": "Failed to get profile"})
+		return
+	}
+
+	profile.Name = req.Name
+
+	if err := h.store.UpdateProfile(c, profile); err != nil {
+		c.JSON(500, gin.H{"error": "Failed to update profile"})
+		return
+	}
+
+	c.JSON(200, profile)
+}
+
+func (h *ProfileHandler) DeleteProfile(c *gin.Context) {
+	id := c.Param("id")
+	if id == "" {
+		c.JSON(400, gin.H{"error": "profile id is required"})
+		return
+	}
+
+	if err := h.store.DeleteProfile(c, id); err != nil {
+		c.JSON(500, gin.H{"error": "Failed to delete profile"})
+		return
+	}
+
+	c.JSON(200, gin.H{"message": "Profile deleted"})
+
+}
+
+func (h *ProfileHandler) GetProfilesByWorkspace(c *gin.Context) {
+	wId := c.Query("workspaceID")
+	if wId == "" {
+		c.JSON(400, gin.H{"error": "workspaceID is required"})
+		return
+	}
+
+	profiles, err := h.store.GetProfilesByWorkspaceID(c, wId)
+	if err != nil {
+		c.JSON(500, gin.H{"error": "Failed to get profiles"})
+		return
+	}
+
+	c.JSON(200, profiles)
+}

+ 393 - 0
handlers/user.go

@@ -0,0 +1,393 @@
+package handlers
+
+import (
+	"time"
+
+	"git.linuxforward.com/byom/byom-core/common"
+	"git.linuxforward.com/byom/byom-core/hook"
+	"git.linuxforward.com/byom/byom-core/jwtutils"
+	"git.linuxforward.com/byom/byom-core/smtp"
+	"git.linuxforward.com/byom/byom-core/store"
+	"github.com/gin-gonic/gin"
+	"github.com/golang-jwt/jwt/v5"
+	"github.com/google/uuid"
+	"github.com/sirupsen/logrus"
+	"golang.org/x/crypto/bcrypt"
+)
+
+type UserHandler struct {
+	store        *store.DataStore
+	emailService *smtp.Service
+	jwtService   *jwtutils.Service
+	logger       *logrus.Entry
+	hook         *hook.HookClient
+}
+
+func NewUserHandler(db *store.DataStore, emailSvc *smtp.Service, jwtSvc *jwtutils.Service, hook *hook.HookClient) *UserHandler {
+
+	logger := logrus.WithField("core", "user-handler")
+
+	return &UserHandler{
+		store:        db,
+		emailService: emailSvc,
+		jwtService:   jwtSvc,
+		logger:       logger,
+		hook:         hook,
+	}
+}
+
+func (h *UserHandler) InitWorkspaceOwner(c *gin.Context) {
+	var req common.InitWorkspaceOwnerRequest
+
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(400, gin.H{"error": "Invalid request"})
+		return
+	}
+
+	user := &common.User{
+		ID:        uuid.New(),
+		Email:     req.Email,
+		Name:      req.Name,
+		Role:      "owner",
+		Status:    "pending",
+		CreatedAt: time.Now(),
+		UpdatedAt: time.Now(),
+	}
+
+	if err := h.store.CreateUser(c, user); err != nil {
+		c.JSON(500, gin.H{"error": "Failed to create user"})
+		return
+	}
+
+	c.JSON(201, user)
+}
+
+func (h *UserHandler) CreateWorkspaceOwner(c *gin.Context) {
+	var req common.CreateWorkspaceOwnerRequest
+
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(400, gin.H{"error": "Invalid request"})
+		return
+	}
+
+	//check if the received email is already the one from the owner
+	tmpOwner, err := h.store.GetUserByEmail(c, req.Email)
+	if err != nil {
+		c.JSON(500, gin.H{"error": "Failed to get user"})
+		return
+	}
+	if tmpOwner.Email != req.Email {
+		c.JSON(400, gin.H{"error": "Email does not match the owner"})
+		return
+	}
+
+	hashedPassword, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
+	user := &common.User{
+		ID:              tmpOwner.ID,
+		Email:           tmpOwner.Email,
+		Name:            tmpOwner.Name,
+		PhoneNumber:     tmpOwner.PhoneNumber,
+		Password:        string(hashedPassword),
+		PasswordCreated: true,
+		Role:            tmpOwner.Role,
+		Status:          "active",
+		CreatedAt:       tmpOwner.CreatedAt,
+		UpdatedAt:       time.Now(),
+	}
+
+	if err := h.store.UpdateUser(c, user); err != nil {
+		c.JSON(500, gin.H{"error": "Failed to create user"})
+		return
+	}
+
+	user.Password = ""
+	c.JSON(201, user)
+}
+
+func (h *UserHandler) CreateInvitedUser(c *gin.Context) {
+	var req common.CreateInvitedUserRequest
+
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(400, gin.H{"error": "Invalid request"})
+		return
+	}
+
+	invite, err := h.store.GetInvite(c, req.Token)
+	if err != nil {
+		c.JSON(400, gin.H{"error": "Invalid invitation"})
+		return
+	}
+
+	tx := h.store.BeginTx()
+
+	hashedPassword, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
+	user := &common.User{
+		ID:              uuid.New(),
+		Email:           req.Email,
+		Name:            req.Name,
+		PhoneNumber:     req.PhoneNumber,
+		Password:        string(hashedPassword),
+		PasswordCreated: true,
+		Role:            invite.Role,
+		Status:          "active",
+		CreatedAt:       time.Now(),
+		UpdatedAt:       time.Now(),
+	}
+
+	if err := h.store.CreateUserTx(c, tx, user); err != nil {
+		tx.Rollback()
+		c.JSON(500, gin.H{"error": "Failed to create user"})
+		return
+	}
+
+	//add user to workspace
+	workspaceID, err := uuid.Parse(invite.Workspace)
+	if err != nil {
+		tx.Rollback()
+		c.JSON(400, gin.H{"error": "Invalid workspace ID"})
+		return
+	}
+	if err := h.store.AddUserToWorkspaceTx(c, tx, invite.Role, user.ID, workspaceID); err != nil {
+		tx.Rollback()
+		c.JSON(500, gin.H{"error": "Failed to add user to workspace"})
+		return
+	}
+
+	//update invite status
+	invite.Status = "accepted"
+
+	if err := h.store.UpdateInviteStatusTx(c, tx, invite); err != nil {
+		tx.Rollback()
+		c.JSON(500, gin.H{"error": "Failed to update invitation"})
+		return
+	}
+
+	if err := tx.Commit().Error; err != nil {
+		c.JSON(500, gin.H{"error": "Failed to complete registration"})
+		return
+	}
+
+	user.Password = ""
+
+	resp := common.CreateInvitedUserResponse{
+		User:     *user,
+		Worspace: invite.Workspace,
+	}
+	c.JSON(201, resp)
+}
+
+// Invite handlers
+func (h *UserHandler) InviteUser(c *gin.Context) {
+	var req common.InviteUserRequest
+
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(400, gin.H{"error": "Invalid request"})
+		return
+	}
+
+	//check if user exists
+	_, err := h.store.GetUserByEmail(c, req.Email)
+	if err == nil {
+		c.JSON(400, gin.H{"error": "User already exists"})
+		return
+	}
+
+	//generate jwt token
+	token, err := h.jwtService.GenerateToken(
+		req.Email,
+		req.Role,
+	)
+	if err != nil {
+		c.JSON(500, gin.H{"error": "Failed to generate token"})
+		return
+	}
+
+	//store token in db
+	invite := &common.Invite{
+		ID:        uuid.New(),
+		Email:     req.Email,
+		Role:      req.Role,
+		Workspace: req.Workspace,
+		Token:     token,
+		Status:    "pending",
+		ExpiresAt: time.Now().Add(24 * time.Hour),
+		CreatedAt: time.Now(),
+		UpdatedAt: time.Now(),
+	}
+
+	//return invite
+	if err := h.store.CreateInvite(c, invite); err != nil {
+		c.JSON(500, gin.H{"error": "Failed to create invitation"})
+		return
+	}
+
+	//send email with token
+	if err := h.emailService.SendInviteEmail(req.Email, token, req.Workspace); err != nil {
+		c.JSON(500, gin.H{"error": "Failed to send email"})
+		return
+	}
+
+	c.JSON(201, invite)
+}
+
+func (h *UserHandler) ValidateInvitedUser(c *gin.Context) {
+	//request token from query
+	token := c.Query("token")
+
+	h.logger.WithField("token", token).Info("Validating invitation")
+
+	inv, err := h.store.GetInvite(c, token)
+	if err != nil {
+		c.JSON(400, gin.H{"error": "Invalid invitation"})
+		return
+	}
+
+	//validate token
+	_, err = h.jwtService.ValidateToken(token)
+	if err != nil {
+		if err == jwt.ErrTokenExpired {
+			//update invite status
+			inv.Status = "expired"
+			if err := h.store.UpdateInvite(c, inv); err != nil {
+				c.JSON(500, gin.H{"error": "Failed to update invitation"})
+				return
+			}
+			c.JSON(400, gin.H{"error": "Invalid token"})
+			return
+		}
+	}
+
+	response := common.ValidateInvitedUserRequest{
+		Valid:       true,
+		WorkspaceID: inv.Workspace,
+		Email:       inv.Email,
+	}
+
+	c.JSON(200, response)
+}
+
+func (h *UserHandler) Login(c *gin.Context) {
+
+	var req common.LoginRequest
+	var resp common.LoginResponse
+
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(400, gin.H{"error": "Invalid request"})
+		return
+	}
+
+	user, err := h.store.GetUserByEmail(c, req.Email)
+	if err != nil {
+		c.JSON(400, gin.H{"error": "Invalid email or password"})
+		return
+	}
+
+	err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password))
+	if err != nil {
+		c.JSON(400, gin.H{"error": "Invalid email or password"})
+		return
+	}
+
+	token, err := h.jwtService.GenerateToken(
+		user.Email,
+		user.Role,
+	)
+	if err != nil {
+		c.JSON(500, gin.H{"error": "Failed to generate token"})
+		return
+	}
+
+	resp.Token = token
+
+	c.JSON(200, resp)
+}
+
+func (h *UserHandler) AddUserToWorkspace(c *gin.Context) {
+	var req common.AddUserToWorkspaceRequest
+
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(400, gin.H{"error": "Invalid request"})
+		return
+	}
+
+	//retrieve user from token
+	claimsValue, exists := c.Get("claims")
+	if !exists {
+		c.JSON(500, gin.H{"error": "Failed to get user claims"})
+		return
+	}
+
+	claims := claimsValue.(jwt.MapClaims)
+	if claims == nil {
+		c.JSON(500, gin.H{"error": "Failed to get user claims"})
+		return
+	}
+
+	email := h.jwtService.ExtractEmailFromClaims(claims)
+	if email == "" {
+		c.JSON(500, gin.H{"error": "Failed to get user email"})
+		return
+	}
+
+	user, err := h.store.GetUserByEmail(c, email)
+	if err != nil {
+		c.JSON(500, gin.H{"error": "Failed to get user"})
+		return
+	}
+
+	//add user to workspace
+	if err := h.store.AddUserToWorkspace(c, user.ID, req.WorkspaceID, req.Role); err != nil {
+		c.JSON(500, gin.H{"error": "Failed to add user to workspace"})
+		return
+	}
+
+	c.JSON(200, gin.H{"message": "User added to workspace"})
+}
+
+func (h *UserHandler) CancelInvitation(c *gin.Context) {
+	// Implementation
+}
+
+func (h *UserHandler) GetCurrentUser(c *gin.Context) {
+	// Get claims from context that were set by middleware
+	claimsValue, exists := c.Get("claims")
+	if !exists {
+		c.JSON(500, gin.H{"error": "Failed to get user claims"})
+		return
+	}
+	claims := claimsValue.(jwt.MapClaims)
+	if claims == nil {
+		c.JSON(500, gin.H{"error": "Failed to get user claims"})
+		return
+	}
+
+	email := h.jwtService.ExtractEmailFromClaims(claims)
+	if email == "" {
+		c.JSON(500, gin.H{"error": "Failed to get user email"})
+		return
+	}
+
+	user, err := h.store.GetUserByEmail(c, email)
+	if err != nil {
+		c.JSON(500, gin.H{"error": "Failed to get user"})
+		return
+	}
+
+	workspaces, err := h.store.GetWorkspacesByUserID(c, user.ID)
+	if err != nil {
+		c.JSON(500, gin.H{"error": "Failed to get user workspace role"})
+		return
+	}
+
+	resp := common.UserMe{
+		User:       *user,
+		Workspaces: workspaces,
+	}
+
+	c.JSON(200, resp)
+
+}
+
+func (h *UserHandler) UpdateCurrentUser(c *gin.Context) {
+	// Implementation
+}

+ 59 - 0
handlers/workspace.go

@@ -0,0 +1,59 @@
+package handlers
+
+import (
+	"net/http"
+
+	"git.linuxforward.com/byom/byom-core/common"
+	"git.linuxforward.com/byom/byom-core/store"
+	"github.com/gin-gonic/gin"
+	"github.com/google/uuid"
+	"github.com/sirupsen/logrus"
+)
+
+type WorkspaceHandler struct {
+	store  *store.DataStore
+	logger *logrus.Entry
+}
+
+func NewWorkspaceHandler(db *store.DataStore) *WorkspaceHandler {
+	logger := logrus.WithField("core", "workspace-handler")
+
+	return &WorkspaceHandler{
+		store:  db,
+		logger: logger,
+	}
+}
+
+type CreateWorkspaceRequest struct {
+	Name string `json:"name" binding:"required"`
+}
+
+type WorkspaceResponse struct {
+	ID   string `json:"id"`
+	Name string `json:"name"`
+}
+
+func (h *WorkspaceHandler) CreateWorkspace(c *gin.Context) {
+	var req CreateWorkspaceRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+		return
+	}
+
+	workspace := &common.Workspace{
+		ID:   uuid.New(),
+		Name: req.Name,
+	}
+
+	if err := h.store.CreateWorkspace(c, workspace); err != nil {
+		h.logger.WithError(err).Error("failed to create user")
+		c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"})
+		return
+	}
+
+	c.JSON(http.StatusCreated, workspace)
+}
+
+func (h *WorkspaceHandler) GetWorkspace(c *gin.Context) {
+	// Implementation
+}

+ 117 - 0
hook/hook.go

@@ -0,0 +1,117 @@
+package hook
+
+import (
+	"bytes"
+	"crypto/hmac"
+	"crypto/sha256"
+	"encoding/hex"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"time"
+)
+
+type HookClient struct {
+	BaseURL    string
+	Domain     string
+	SecretKey  string
+	HTTPClient *http.Client
+}
+
+type HookPayload struct {
+	Email     string    `json:"email"`
+	Domain    string    `json:"domain"`
+	Action    string    `json:"action"`
+	Timestamp time.Time `json:"timestamp"`
+}
+
+func NewHookClient(baseURL, domain, secretKey string) *HookClient {
+	return &HookClient{
+		BaseURL:   baseURL,
+		Domain:    domain,
+		SecretKey: secretKey,
+		HTTPClient: &http.Client{
+			Timeout: 10 * time.Second,
+		},
+	}
+}
+
+func (c *HookClient) SendWebhook(email, action string) error {
+	payload := HookPayload{
+		Email:     email,
+		Domain:    c.Domain,
+		Action:    action,
+		Timestamp: time.Now(),
+	}
+
+	payloadBytes, err := json.Marshal(payload)
+	if err != nil {
+		return fmt.Errorf("erreur de marshalling: %w", err)
+	}
+
+	// Calcul de la signature
+	signature := c.calculateHMAC(payloadBytes)
+
+	// Création de la requête
+	req, err := http.NewRequest(
+		"POST",
+		fmt.Sprintf("%s/webhook/user-sync", c.BaseURL),
+		bytes.NewBuffer(payloadBytes),
+	)
+	if err != nil {
+		return fmt.Errorf("erreur de création de requête: %w", err)
+	}
+
+	// Ajout des headers
+	req.Header.Set("Content-Type", "application/json")
+	req.Header.Set("X-Webhook-Signature", signature)
+	req.Header.Set("X-Webhook-Domain", c.Domain)
+
+	// Envoi de la requête avec retry
+	var lastErr error
+	for retry := 0; retry < 3; retry++ {
+		resp, err := c.HTTPClient.Do(req)
+		if err != nil {
+			lastErr = err
+			time.Sleep(time.Duration(retry+1) * time.Second)
+			continue
+		}
+		defer resp.Body.Close()
+
+		if resp.StatusCode == http.StatusOK {
+			return nil
+		}
+
+		if resp.StatusCode == http.StatusTooManyRequests {
+			// Attendre avant de réessayer
+			time.Sleep(time.Duration(retry+1) * time.Second)
+			continue
+		}
+
+		// Erreur définitive
+		if resp.StatusCode >= 400 && resp.StatusCode != http.StatusTooManyRequests {
+			return fmt.Errorf("erreur HTTP %d", resp.StatusCode)
+		}
+	}
+
+	return fmt.Errorf("échec après 3 tentatives: %w", lastErr)
+}
+
+func (c *HookClient) calculateHMAC(message []byte) string {
+	mac := hmac.New(sha256.New, []byte(c.SecretKey))
+	mac.Write(message)
+	return hex.EncodeToString(mac.Sum(nil))
+}
+
+func (c *HookClient) Close() error {
+	if c.HTTPClient != nil {
+		// Close idle connections
+		if transport, ok := c.HTTPClient.Transport.(*http.Transport); ok {
+			transport.CloseIdleConnections()
+		}
+
+		// Clear the client
+		c.HTTPClient = nil
+	}
+	return nil
+}

+ 0 - 0
internal/cache/cache.go


+ 0 - 0
internal/cache/models.go


+ 73 - 0
jwtutils/service.go

@@ -0,0 +1,73 @@
+package jwtutils
+
+import (
+	"time"
+
+	"github.com/golang-jwt/jwt/v5"
+	"github.com/sirupsen/logrus"
+)
+
+// Service is the service that provides JWT functionalities
+// Custom payload can be added to the token
+
+type Service struct {
+	secret string
+	logger *logrus.Entry
+}
+
+func NewService(secret string) *Service {
+	logger := logrus.WithField("core", "jwt")
+	return &Service{
+		secret: secret,
+		logger: logger,
+	}
+}
+
+func (s *Service) GenerateToken(email, role string) (string, error) {
+	now := time.Now()
+	claims := jwt.MapClaims{
+		"sub":  email,
+		"exp":  now.Add(24 * time.Hour).Unix(),
+		"iat":  now.Unix(),
+		"role": role,
+	}
+	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
+	signedToken, err := token.SignedString([]byte(s.secret))
+	if err != nil {
+		s.logger.WithError(err).Error("failed to sign token")
+		return "", err
+	}
+
+	return signedToken, nil
+}
+
+func (s *Service) ValidateToken(token string) (jwt.MapClaims, error) {
+	parsedToken, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) {
+		return []byte(s.secret), nil
+	})
+	if err != nil {
+		s.logger.WithError(err).Error("failed to parse token")
+		return nil, err
+	}
+
+	claims, ok := parsedToken.Claims.(jwt.MapClaims)
+	if !ok || !parsedToken.Valid {
+		return nil, jwt.ErrSignatureInvalid
+	}
+
+	if exp, ok := claims["exp"].(float64); ok {
+		if time.Now().Unix() > int64(exp) {
+			return nil, jwt.ErrTokenExpired
+		}
+	}
+
+	return claims, nil
+}
+
+func (s *Service) ExtractEmailFromClaims(claims jwt.MapClaims) string {
+	email, ok := claims["sub"].(string)
+	if !ok {
+		return ""
+	}
+	return email
+}

+ 0 - 0
logger/logger.go


+ 53 - 0
main.go

@@ -0,0 +1,53 @@
+package main
+
+import (
+	"fmt"
+
+	"git.linuxforward.com/byom/byom-core/app"
+	"git.linuxforward.com/byom/byom-core/config"
+	"github.com/alecthomas/kong"
+)
+
+var (
+	version = "0.0.1-dev"
+	commit  = "none"
+	date    = "unknown"
+)
+
+var cli struct {
+	Serve   ServeCmd   `cmd:"serve" help:"Run the server"`
+	Version VersionCmd `cmd:"version" help:"Print version information"`
+}
+
+type ServeCmd struct {
+	Config string `type:"path" help:"Path to the configuration file." default:"config.yaml"`
+}
+
+func (c *ServeCmd) Run() error {
+	cnf, err := config.ReadConfig(c.Config)
+	if err != nil {
+		return err
+	}
+
+	app, err := app.NewApp(cnf)
+	if err != nil {
+		return err
+	}
+
+	return app.Run()
+}
+
+type VersionCmd struct{}
+
+func (c *VersionCmd) Run() error {
+
+	fmt.Printf("byom-core %s, commit %s, built at %s", version, commit, date)
+	return nil
+}
+
+func main() {
+	ctx := kong.Parse(&cli)
+	err := ctx.Run()
+	ctx.FatalIfErrorf(err)
+
+}

+ 0 - 0
pkg/apiclient/client.go


+ 0 - 0
pkg/apiclient/interfaces.go


+ 0 - 0
pkg/apiclient/models.go


+ 0 - 0
pkg/validator/validator.go


+ 85 - 0
smtp/service.go

@@ -0,0 +1,85 @@
+package smtp
+
+import (
+	"bytes"
+	"embed"
+	"fmt"
+	"html/template"
+	"net/smtp"
+
+	"git.linuxforward.com/byom/byom-core/config"
+	"github.com/sirupsen/logrus"
+)
+
+type Service struct {
+	client   *smtp.Client
+	template *template.Template
+	logger   *logrus.Entry
+}
+
+//go:embed templates/invite.html
+var inviteTpl embed.FS
+
+type EmailData struct {
+	Token         string
+	WorkspaceName string
+	URL           string
+}
+
+func NewService(cnf *config.Smtp) *Service {
+
+	logger := logrus.WithField("core", "email")
+
+	//mock the smtp client
+	client := &smtp.Client{}
+
+	//parse the email template
+	tmpl, err := template.ParseFS(inviteTpl, "templates/invite.html")
+	if err != nil {
+		logger.WithError(err).Error("failed to parse email template")
+		panic(err)
+	}
+
+	return &Service{
+		logger:   logger,
+		client:   client,
+		template: tmpl,
+	}
+
+}
+
+func (s *Service) SendEmail(to, subject, body string) error {
+	s.logger.WithField("to", to).WithField("subject", subject).Info("sending email")
+	//since we are not actually sending emails, we will just log the email
+	s.logger.WithField("body", body).Info("email body")
+	return nil
+}
+
+func (s *Service) SendInviteEmail(to, token, workspace string) error {
+	var body bytes.Buffer
+	data := EmailData{
+		Token:         token,
+		WorkspaceName: workspace,
+		URL:           fmt.Sprintf("https://%s.domain.com/invite/%s", workspace, token),
+	}
+
+	if err := s.template.Execute(&body, data); err != nil {
+		return fmt.Errorf("template execution failed: %w", err)
+	}
+	return s.SendEmail(to, "Join "+workspace+" workspace", body.String())
+}
+
+func (s *Service) Close() error {
+	if s.client != nil {
+		err := s.client.Close()
+		if err != nil {
+			s.logger.WithError(err).Error("failed to close SMTP client")
+			return fmt.Errorf("close SMTP client: %w", err)
+		}
+		s.logger.Info("SMTP client closed successfully")
+	}
+
+	s.template = nil
+
+	return nil
+}

+ 61 - 0
smtp/templates/invite.html

@@ -0,0 +1,61 @@
+<!DOCTYPE html>
+<html>
+<head>
+   <meta charset="UTF-8">
+   <style>
+       .container {
+           max-width: 600px;
+           margin: 0 auto;
+           font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+           color: #333;
+           padding: 20px;
+       }
+       .header {
+           background: #2563eb;
+           color: white;
+           padding: 20px;
+           border-radius: 8px;
+           text-align: center;
+       }
+       .content {
+           background: white;
+           padding: 30px;
+           border-radius: 8px;
+           margin-top: 20px;
+           box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+       }
+       .button {
+           display: inline-block;
+           padding: 12px 24px;
+           background: #2563eb;
+           color: white;
+           text-decoration: none;
+           border-radius: 6px;
+           margin: 20px 0;
+       }
+       .footer {
+           text-align: center;
+           color: #666;
+           font-size: 14px;
+           margin-top: 30px;
+       }
+   </style>
+</head>
+<body style="background-color: #f3f4f6; margin: 0; padding: 20px;">
+   <div class="container">
+       <div class="header">
+           <h1>You're invited!</h1>
+       </div>
+       <div class="content">
+           <p>Hi there,</p>
+           <p>You've been invited to join our workspace. Click the button below to accept the invitation and get started.</p>
+           <a href="{{.URL}}" class="button">Accept Invitation</a>
+           <p>If the button doesn't work, copy and paste this link into your browser:</p>
+           <p style="word-break: break-all; color: #666;">{{.URL}}</p>
+       </div>
+       <div class="footer">
+           <p>This invitation expires in 24 hours.</p>
+       </div>
+   </div>
+</body>
+</html>

+ 73 - 0
store/interfaces.go

@@ -0,0 +1,73 @@
+package store
+
+import (
+	"fmt"
+
+	"git.linuxforward.com/byom/byom-core/common"
+	"github.com/sirupsen/logrus"
+	"gorm.io/gorm"
+)
+
+type DataStore struct {
+	logger *logrus.Entry
+	db     *gorm.DB
+}
+
+func NewDataStore(db *gorm.DB) *DataStore {
+	logger := logrus.WithField("core", "user-store")
+
+	InitTables(db)
+	return &DataStore{
+		db:     db,
+		logger: logger,
+	}
+}
+
+func InitTables(db *gorm.DB) error {
+	err := db.AutoMigrate(&common.User{}, &common.Invite{}, &common.UserWorkspaceRole{}, &common.Workspace{}, &common.Profile{})
+	if err != nil {
+		return err
+	}
+
+	// check if there are any invites that are pending and have expired
+	var invites []common.Invite
+	result := db.Where("status = ? AND expires_at < ?", "pending", "now()").Find(&invites)
+	if result.Error != nil {
+		return fmt.Errorf("get expired invites: %w", result.Error)
+	}
+
+	// update the status of expired invites
+	for _, invite := range invites {
+		result := db.Model(&common.Invite{}).Where("token = ?", invite.Token).Update("status", "expired")
+		if result.Error != nil {
+			return fmt.Errorf("update expired invite status: %w", result.Error)
+		}
+	}
+	return nil
+}
+
+//Tx methods
+
+func (s *DataStore) BeginTx() *gorm.DB {
+	return s.db.Begin()
+}
+
+func (s *DataStore) CommitTx(tx *gorm.DB) error {
+	return tx.Commit().Error
+}
+
+func (s *DataStore) Close() error {
+	s.logger.Info("Closing database connections")
+
+	sqlDB, err := s.db.DB()
+	if err != nil {
+		return fmt.Errorf("get sql.DB instance: %w", err)
+	}
+
+	if err := sqlDB.Close(); err != nil {
+		return fmt.Errorf("close database connections: %w", err)
+	}
+
+	s.logger.Info("Database connections closed successfully")
+	return nil
+}

+ 67 - 0
store/invite.go

@@ -0,0 +1,67 @@
+package store
+
+import (
+	"context"
+	"fmt"
+
+	"git.linuxforward.com/byom/byom-core/common"
+	"gorm.io/gorm"
+)
+
+//CRUD methods for the invite
+
+// CreateInvite creates a new invite
+func (s *DataStore) CreateInvite(ctx context.Context, i *common.Invite) error {
+	result := s.db.Create(i)
+	if result.Error != nil {
+		return fmt.Errorf("create invite: %w", result.Error)
+	}
+	return nil
+}
+
+// GetInvite gets an invite by ID
+func (s *DataStore) GetInvite(ctx context.Context, id string) (*common.Invite, error) {
+	var invite common.Invite
+	result := s.db.First(&invite, id)
+	if result.Error != nil {
+		return nil, fmt.Errorf("get invite: %w", result.Error)
+	}
+	return &invite, nil
+}
+
+// UpdateInvite updates an invite
+func (s *DataStore) UpdateInvite(ctx context.Context, i *common.Invite) error {
+	result := s.db.Save(i)
+	if result.Error != nil {
+		return fmt.Errorf("update invite: %w", result.Error)
+	}
+	return nil
+}
+
+// DeleteInvite deletes an invite
+func (s *DataStore) DeleteInvite(ctx context.Context, id string) error {
+	result := s.db.Delete(&common.Invite{}, id)
+	if result.Error != nil {
+		return fmt.Errorf("delete invite: %w", result.Error)
+	}
+	return nil
+}
+
+// GetInvites gets all invites
+func (s *DataStore) GetInvites(ctx context.Context) ([]common.Invite, error) {
+	var invites []common.Invite
+	result := s.db.Find(&invites)
+	if result.Error != nil {
+		return nil, fmt.Errorf("get invites: %w", result.Error)
+	}
+	return invites, nil
+}
+
+// UpdateInviteStatusTx updates the status of an invite in a transaction
+func (s *DataStore) UpdateInviteStatusTx(ctx context.Context, tx *gorm.DB, i *common.Invite) error {
+	result := tx.Model(&common.Invite{}).Where("token = ?", i.Token).Update("status", i.Status)
+	if result.Error != nil {
+		return fmt.Errorf("update invite status: %w", result.Error)
+	}
+	return nil
+}

+ 80 - 0
store/profile.go

@@ -0,0 +1,80 @@
+package store
+
+import (
+	"context"
+	"fmt"
+
+	"git.linuxforward.com/byom/byom-core/common"
+	"github.com/google/uuid"
+)
+
+//CRUD methods for the profile
+//a profile can be attached to a workspace
+//a workspace can have multiple profiles
+
+// CreateProfile creates a new profile
+func (s *DataStore) CreateProfile(ctx context.Context, p *common.Profile) error {
+	result := s.db.Create(p)
+	if result.Error != nil {
+		return fmt.Errorf("create profile: %w", result.Error)
+	}
+	return nil
+}
+
+func (s *DataStore) GetProfile(ctx context.Context, id string) (*common.Profile, error) {
+	var profile common.Profile
+	result := s.db.First(&profile, id)
+	if result.Error != nil {
+		return nil, fmt.Errorf("get profile: %w", result.Error)
+	}
+	return &profile, nil
+}
+
+// UpdateProfile updates a profile
+func (s *DataStore) UpdateProfile(ctx context.Context, p *common.Profile) error {
+	result := s.db.Save(p)
+	if result.Error != nil {
+		return fmt.Errorf("update profile: %w", result.Error)
+	}
+	return nil
+}
+
+// DeleteProfile deletes a profile
+func (s *DataStore) DeleteProfile(ctx context.Context, id string) error {
+	result := s.db.Delete(&common.Profile{}, id)
+	if result.Error != nil {
+		return fmt.Errorf("delete profile: %w", result.Error)
+	}
+	return nil
+}
+
+// GetProfiles gets all profiles
+func (s *DataStore) GetProfiles(ctx context.Context) ([]common.Profile, error) {
+	var profiles []common.Profile
+	result := s.db.Find(&profiles)
+	if result.Error != nil {
+		return nil, fmt.Errorf("get profiles: %w", result.Error)
+	}
+	return profiles, nil
+}
+
+// GetProfilesByWorkspaceID gets all profiles by workspace ID
+func (s *DataStore) GetProfilesByWorkspaceID(ctx context.Context, workspaceID string) ([]common.Profile, error) {
+	var profiles []common.Profile
+	result := s.db.Where("workspace_id = ?", workspaceID).Find(&profiles)
+	if result.Error != nil {
+		return nil, fmt.Errorf("get profiles by workspace ID: %w", result.Error)
+	}
+	return profiles, nil
+}
+
+// AttachProfileToWorkspace attaches a profile to a workspace
+func (s *DataStore) AttachProfileToWorkspace(ctx context.Context, profileID, workspaceID uuid.UUID) error {
+	err := s.db.Model(&common.Profile{ID: profileID}).
+		Association("Workspaces").
+		Append(&common.Workspace{ID: workspaceID})
+	if err != nil {
+		return fmt.Errorf("attach profile to workspace: %w", err)
+	}
+	return nil
+}

+ 58 - 0
store/user.go

@@ -0,0 +1,58 @@
+package store
+
+import (
+	"context"
+	"fmt"
+
+	"git.linuxforward.com/byom/byom-core/common"
+	"gorm.io/gorm"
+)
+
+//CRUD methods for the user
+
+// CreateUser creates a new user
+func (s *DataStore) CreateUser(ctx context.Context, u *common.User) error {
+	result := s.db.Create(u)
+	if result.Error != nil {
+		return fmt.Errorf("create user: %w", result.Error)
+	}
+	return nil
+}
+
+// GetUser gets a user by ID
+func (s *DataStore) GetUser(ctx context.Context, id string) (*common.User, error) {
+	var user common.User
+	result := s.db.First(&user, id)
+	if result.Error != nil {
+		return nil, fmt.Errorf("get user: %w", result.Error)
+	}
+	return &user, nil
+}
+
+// GetUserByEmail gets a user by email
+func (s *DataStore) GetUserByEmail(ctx context.Context, email string) (*common.User, error) {
+	var user common.User
+	result := s.db.Where("email = ?", email).First(&user)
+	if result.Error != nil {
+		return nil, fmt.Errorf("get user by email: %w", result.Error)
+	}
+	return &user, nil
+}
+
+// UpdateUser updates a user
+func (s *DataStore) UpdateUser(ctx context.Context, u *common.User) error {
+	result := s.db.Save(u)
+	if result.Error != nil {
+		return fmt.Errorf("update user: %w", result.Error)
+	}
+	return nil
+}
+
+// CreateUserTx creates a new user in a transaction
+func (s *DataStore) CreateUserTx(ctx context.Context, tx *gorm.DB, u *common.User) error {
+	result := tx.Create(u)
+	if result.Error != nil {
+		return fmt.Errorf("create user: %w", result.Error)
+	}
+	return nil
+}

+ 162 - 0
store/workspace.go

@@ -0,0 +1,162 @@
+package store
+
+import (
+	"context"
+	"fmt"
+
+	"git.linuxforward.com/byom/byom-core/common"
+	"github.com/google/uuid"
+	"gorm.io/gorm"
+)
+
+//CRUD methods for the workspace
+
+// CreateWorkspace creates a new workspace
+func (s *DataStore) CreateWorkspace(ctx context.Context, w *common.Workspace) error {
+	result := s.db.Create(w)
+	if result.Error != nil {
+		return fmt.Errorf("create workspace: %w", result.Error)
+	}
+	return nil
+}
+
+// GetWorkspace gets a workspace by ID
+func (s *DataStore) GetWorkspace(ctx context.Context, id string) (*common.Workspace, error) {
+	var workspace common.Workspace
+	result := s.db.First(&workspace, id)
+	if result.Error != nil {
+		return nil, fmt.Errorf("get workspace: %w", result.Error)
+	}
+	return &workspace, nil
+}
+
+// UpdateWorkspace updates a workspace
+func (s *DataStore) UpdateWorkspace(ctx context.Context, w *common.Workspace) error {
+	result := s.db.Save(w)
+	if result.Error != nil {
+		return fmt.Errorf("update workspace: %w", result.Error)
+	}
+	return nil
+}
+
+// DeleteWorkspace deletes a workspace
+func (s *DataStore) DeleteWorkspace(ctx context.Context, id string) error {
+	result := s.db.Delete(&common.Workspace{}, id)
+	if result.Error != nil {
+		return fmt.Errorf("delete workspace: %w", result.Error)
+	}
+	return nil
+}
+
+// GetWorkspaces gets all workspaces
+func (s *DataStore) GetWorkspaces(ctx context.Context) ([]common.Workspace, error) {
+	var workspaces []common.Workspace
+	result := s.db.Find(&workspaces)
+	if result.Error != nil {
+		return nil, fmt.Errorf("get workspaces: %w", result.Error)
+	}
+	return workspaces, nil
+}
+
+// GetWorkspacesByUserID gets all workspaces by user ID
+func (s *DataStore) GetWorkspacesByUserID(ctx context.Context, userID uuid.UUID) ([]common.Workspace, error) {
+	var workspaces []common.Workspace
+	err := s.db.Model(&common.User{ID: userID}).
+		Association("Workspaces").
+		Find(&workspaces)
+
+	if err != nil {
+		return nil, fmt.Errorf("get workspaces by user ID: %w", err)
+	}
+	return workspaces, nil
+}
+
+// GetWorkspaceByUserID gets a workspace by user ID
+func (s *DataStore) GetWorkspaceByUserID(ctx context.Context, userID, workspaceID uuid.UUID) (*common.Workspace, error) {
+	var workspace common.Workspace
+	err := s.db.Model(&common.User{ID: userID}).
+		Association("Workspaces").
+		Find(&workspace, workspaceID)
+
+	if err != nil {
+		return nil, fmt.Errorf("get workspace by user ID: %w", err)
+	}
+	return &workspace, nil
+}
+
+// AddUserToWorkspace adds a user to a workspace
+func (s *DataStore) AddUserToWorkspace(ctx context.Context, userID, workspaceID uuid.UUID, role string) error {
+	uwr := common.UserWorkspaceRole{
+		UserID:      userID,
+		WorkspaceID: workspaceID,
+		Role:        role,
+	}
+
+	result := s.db.Create(&uwr)
+	if result.Error != nil {
+		return fmt.Errorf("add user to workspace: %w", result.Error)
+	}
+	return nil
+}
+
+// AddUserToWorkspaceTx adds a user to a workspace in a transaction
+func (s *DataStore) AddUserToWorkspaceTx(ctx context.Context, tx *gorm.DB, role string, userID, workspaceID uuid.UUID) error {
+	uwr := common.UserWorkspaceRole{
+		UserID:      userID,
+		WorkspaceID: workspaceID,
+		Role:        role,
+	}
+
+	result := tx.Create(&uwr)
+	if result.Error != nil {
+		return fmt.Errorf("add user to workspace in transaction: %w", result.Error)
+	}
+	return nil
+}
+
+// GetUserWorkspaceRole gets a user's role in a workspace
+func (s *DataStore) GetUserWorkspaceRole(ctx context.Context, userID, workspaceID string) (*common.UserWorkspaceRole, error) {
+	var uwr common.UserWorkspaceRole
+	result := s.db.Where("user_id = ? AND workspace_id = ?", userID, workspaceID).First(&uwr)
+	if result.Error != nil {
+		return nil, fmt.Errorf("get user workspace role: %w", result.Error)
+	}
+	return &uwr, nil
+}
+
+// UpdateUserWorkspaceRole updates a user's role in a workspace
+func (s *DataStore) UpdateUserWorkspaceRole(ctx context.Context, userID, workspaceID, newRole string) error {
+	result := s.db.Model(&common.UserWorkspaceRole{}).
+		Where("user_id = ? AND workspace_id = ?", userID, workspaceID).
+		Update("role", newRole)
+	if result.Error != nil {
+		return fmt.Errorf("update user workspace role: %w", result.Error)
+	}
+	return nil
+}
+
+// DeleteUserFromWorkspace deletes a user from a workspace
+func (s *DataStore) DeleteUserFromWorkspace(ctx context.Context, userID, workspaceID string) error {
+	result := s.db.Where("user_id = ? AND workspace_id = ?", userID, workspaceID).Delete(&common.UserWorkspaceRole{})
+	if result.Error != nil {
+		return fmt.Errorf("delete user from workspace: %w", result.Error)
+	}
+	return nil
+}
+
+// GetWorkspaceUsers gets all users in a workspace
+func (s *DataStore) GetWorkspaceUsers(ctx context.Context, workspaceID string) ([]common.User, error) {
+	var users []common.User
+	workspaceUUID, err := uuid.Parse(workspaceID)
+	if err != nil {
+		return nil, fmt.Errorf("invalid workspace ID: %w", err)
+	}
+	err = s.db.Model(&common.Workspace{ID: workspaceUUID}).
+		Association("Users").
+		Find(&users)
+
+	if err != nil {
+		return nil, fmt.Errorf("get workspace users: %w", err)
+	}
+	return users, nil
+}