diff --git a/go.mod b/go.mod index a55db52..07d0883 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/google/uuid v1.6.0 go.uber.org/dig v1.17.1 golang.org/x/crypto v0.39.0 - gorm.io/driver/sqlite v1.5.6 + gorm.io/driver/postgres v1.5.9 gorm.io/gorm v1.25.11 ) @@ -20,23 +20,28 @@ require ( github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/spec v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgx/v5 v5.5.5 // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/klauspost/compress v1.17.9 // indirect github.com/mailru/easyjson v0.7.7 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/rivo/uniseg v0.2.0 // indirect + github.com/stretchr/testify v1.10.0 // indirect github.com/swaggo/files/v2 v2.0.0 // indirect github.com/swaggo/swag v1.16.3 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.51.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect + golang.org/x/sync v0.15.0 // indirect golang.org/x/sys v0.33.0 // indirect golang.org/x/text v0.26.0 // indirect - golang.org/x/tools v0.33.0 // indirect + golang.org/x/tools v0.34.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index f184a35..0c04462 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,7 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= @@ -20,6 +21,14 @@ github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXe github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= 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/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= +github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 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= @@ -34,23 +43,23 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 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-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw= github.com/swaggo/files/v2 v2.0.0/go.mod h1:24kk2Y9NYEJ5lHuCra6iVwkMjIekMCaFq/0JQj66kyM= github.com/swaggo/swag v1.16.3 h1:PnCYjPCah8FK4I26l2F/KQ4yz3sILcVUN3cTlBFA9Pg= @@ -69,20 +78,20 @@ golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= -golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= -golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/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= +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.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE= -gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4= +gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8= +gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg= gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= diff --git a/local/api/api.go b/local/api/api.go index caf8954..d1e28b4 100644 --- a/local/api/api.go +++ b/local/api/api.go @@ -2,6 +2,9 @@ package api import ( "omega-server/local/controller" + "omega-server/local/graphql/handler" + graphqlService "omega-server/local/graphql/service" + "omega-server/local/service" "omega-server/local/utl/common" "omega-server/local/utl/configs" "omega-server/local/utl/logging" @@ -33,5 +36,35 @@ func Init(di *dig.Container, app *fiber.App) { logging.Panic("unable to bind routes") } + // Initialize GraphQL + initGraphQL(di, groups) + controller.InitializeControllers(di) } + +// initGraphQL initializes GraphQL endpoints +func initGraphQL(di *dig.Container, groups fiber.Router) { + err := di.Invoke(func(membershipService *service.MembershipService) error { + // Create GraphQL service + gqlService := graphqlService.NewGraphQLService(membershipService) + _ = gqlService // Use the service (placeholder) + + // Create GraphQL handler + graphqlHandler := handler.NewGraphQLHandler(membershipService) + + // Register GraphQL routes + groups.Post("/graphql", graphqlHandler.Handle) + + // GraphQL playground/schema endpoint + groups.Get("/graphql", func(c *fiber.Ctx) error { + return c.SendString(graphqlHandler.GetSchema()) + }) + + logging.Info("GraphQL endpoint initialized at /graphql") + return nil + }) + + if err != nil { + logging.Panic("failed to initialize GraphQL: " + err.Error()) + } +} diff --git a/local/controller/membership.go b/local/controller/membership.go index 940fe33..c80e16b 100644 --- a/local/controller/membership.go +++ b/local/controller/membership.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "omega-server/local/middleware" + "omega-server/local/model" "omega-server/local/service" "omega-server/local/utl/common" "omega-server/local/utl/error_handler" @@ -52,7 +53,7 @@ func NewMembershipController(service *service.MembershipService, auth *middlewar // Login handles user login. func (c *MembershipController) Login(ctx *fiber.Ctx) error { type request struct { - Username string `json:"username"` + Email string `json:"email"` Password string `json:"password"` } @@ -62,7 +63,7 @@ func (c *MembershipController) Login(ctx *fiber.Ctx) error { } logging.Debug("Login request received") - token, err := c.service.Login(ctx.UserContext(), req.Username, req.Password) + token, err := c.service.Login(ctx.UserContext(), req.Email, req.Password) if err != nil { return c.errorHandler.HandleAuthError(ctx, err) } @@ -72,23 +73,35 @@ func (c *MembershipController) Login(ctx *fiber.Ctx) error { // CreateUser creates a new user. func (mc *MembershipController) CreateUser(c *fiber.Ctx) error { - type request struct { - Username string `json:"username"` - Password string `json:"password"` - Role string `json:"role"` - } - - var req request + var req model.UserCreateRequest if err := c.BodyParser(&req); err != nil { return mc.errorHandler.HandleParsingError(c, err) } - user, err := mc.service.CreateUser(c.UserContext(), req.Username, req.Password, req.Role) + // Validate request + if err := req.Validate(); err != nil { + return mc.errorHandler.HandleValidationError(c, err, "user_create_request") + } + + // Map to domain model + user, err := req.ToUser() + if err != nil { + return mc.errorHandler.HandleValidationError(c, err, "user_mapping") + } + + // Extract role names from request + roleIDs := req.RoleIDs + if len(roleIDs) == 0 { + roleIDs = []string{"user"} // default role + } + + // Call service with domain model + createdUser, err := mc.service.CreateUser(c.UserContext(), user, roleIDs) if err != nil { return mc.errorHandler.HandleServiceError(c, err) } - return c.JSON(user) + return c.JSON(createdUser.ToResponse()) } // ListUsers lists all users. @@ -98,7 +111,13 @@ func (mc *MembershipController) ListUsers(c *fiber.Ctx) error { return mc.errorHandler.HandleServiceError(c, err) } - return c.JSON(users) + // Convert to response format + userResponses := make([]*model.UserResponse, len(users)) + for i, user := range users { + userResponses[i] = user.ToResponse() + } + + return c.JSON(userResponses) } // GetUser gets a single user by ID. @@ -113,7 +132,7 @@ func (mc *MembershipController) GetUser(c *fiber.Ctx) error { return mc.errorHandler.HandleNotFoundError(c, "User") } - return c.JSON(user) + return c.JSON(user.ToResponse()) } // GetMe returns the currently authenticated user's details. @@ -128,10 +147,7 @@ func (mc *MembershipController) GetMe(c *fiber.Ctx) error { return mc.errorHandler.HandleNotFoundError(c, "User") } - // Sanitize the user object to not expose password - user.PasswordHash = "" - - return c.JSON(user) + return c.JSON(user.ToResponse()) } // DeleteUser deletes a user. @@ -156,17 +172,22 @@ func (mc *MembershipController) UpdateUser(c *fiber.Ctx) error { return mc.errorHandler.HandleUUIDError(c, "user ID") } - var req service.UpdateUserRequest + var req model.UserUpdateRequest if err := c.BodyParser(&req); err != nil { return mc.errorHandler.HandleParsingError(c, err) } - user, err := mc.service.UpdateUser(c.UserContext(), id, req) + // Validate request + if err := req.Validate(); err != nil { + return mc.errorHandler.HandleValidationError(c, err, "user_update_request") + } + + user, err := mc.service.UpdateUser(c.UserContext(), id, &req) if err != nil { return mc.errorHandler.HandleServiceError(c, err) } - return c.JSON(user) + return c.JSON(user.ToResponse()) } // GetRoles returns all available roles. diff --git a/local/graphql/handler/handler.go b/local/graphql/handler/handler.go new file mode 100644 index 0000000..91b7f4c --- /dev/null +++ b/local/graphql/handler/handler.go @@ -0,0 +1,421 @@ +package handler + +import ( + "context" + "omega-server/local/model" + "omega-server/local/service" + "strings" + + "github.com/gofiber/fiber/v2" +) + +// GraphQLHandler handles GraphQL requests +type GraphQLHandler struct { + membershipService *service.MembershipService +} + +// NewGraphQLHandler creates a new GraphQL handler +func NewGraphQLHandler(membershipService *service.MembershipService) *GraphQLHandler { + return &GraphQLHandler{ + membershipService: membershipService, + } +} + +// GraphQLRequest represents a GraphQL request +type GraphQLRequest struct { + Query string `json:"query"` + Variables map[string]interface{} `json:"variables"` +} + +// GraphQLResponse represents a GraphQL response +type GraphQLResponse struct { + Data interface{} `json:"data,omitempty"` + Errors []GraphQLError `json:"errors,omitempty"` +} + +// GraphQLError represents a GraphQL error +type GraphQLError struct { + Message string `json:"message"` + Path []string `json:"path,omitempty"` +} + +// Handle processes GraphQL requests +func (h *GraphQLHandler) Handle(c *fiber.Ctx) error { + var req GraphQLRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(GraphQLResponse{ + Errors: []GraphQLError{{Message: "Invalid request body"}}, + }) + } + + // Basic query parsing and handling + ctx := c.UserContext() + + // Simple query routing based on query string + query := strings.TrimSpace(req.Query) + + switch { + case strings.Contains(query, "mutation") && strings.Contains(query, "login"): + return h.handleLogin(c, ctx, req) + case strings.Contains(query, "mutation") && strings.Contains(query, "createUser"): + return h.handleCreateUser(c, ctx, req) + case strings.Contains(query, "mutation") && strings.Contains(query, "createProject"): + return h.handleCreateProject(c, ctx, req) + case strings.Contains(query, "mutation") && strings.Contains(query, "createTask"): + return h.handleCreateTask(c, ctx, req) + case strings.Contains(query, "query") && strings.Contains(query, "me"): + return h.handleMe(c, ctx, req) + case strings.Contains(query, "query") && strings.Contains(query, "users"): + return h.handleUsers(c, ctx, req) + case strings.Contains(query, "query") && strings.Contains(query, "projects"): + return h.handleProjects(c, ctx, req) + case strings.Contains(query, "query") && strings.Contains(query, "tasks"): + return h.handleTasks(c, ctx, req) + default: + return c.Status(400).JSON(GraphQLResponse{ + Errors: []GraphQLError{{Message: "Query not supported"}}, + }) + } +} + +// handleLogin handles login mutations +func (h *GraphQLHandler) handleLogin(c *fiber.Ctx, ctx context.Context, req GraphQLRequest) error { + // Extract variables + email, ok := req.Variables["email"].(string) + if !ok { + return c.Status(400).JSON(GraphQLResponse{ + Errors: []GraphQLError{{Message: "Email is required"}}, + }) + } + + password, ok := req.Variables["password"].(string) + if !ok { + return c.Status(400).JSON(GraphQLResponse{ + Errors: []GraphQLError{{Message: "Password is required"}}, + }) + } + + // Call service + token, err := h.membershipService.Login(ctx, email, password) + if err != nil { + return c.Status(401).JSON(GraphQLResponse{ + Errors: []GraphQLError{{Message: "Invalid credentials"}}, + }) + } + + // Mock user data for now + userData := map[string]interface{}{ + "id": "1", + "email": email, + "fullName": "User", + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z", + } + + response := map[string]interface{}{ + "login": map[string]interface{}{ + "token": token, + "user": userData, + }, + } + + return c.JSON(GraphQLResponse{Data: response}) +} + +// handleCreateUser handles user creation mutations +func (h *GraphQLHandler) handleCreateUser(c *fiber.Ctx, ctx context.Context, req GraphQLRequest) error { + // Extract variables + email, ok := req.Variables["email"].(string) + if !ok { + return c.Status(400).JSON(GraphQLResponse{ + Errors: []GraphQLError{{Message: "Email is required"}}, + }) + } + + password, ok := req.Variables["password"].(string) + if !ok { + return c.Status(400).JSON(GraphQLResponse{ + Errors: []GraphQLError{{Message: "Password is required"}}, + }) + } + + fullName, ok := req.Variables["fullName"].(string) + if !ok { + fullName = "" + } + + // Create domain model + user := &model.User{ + Email: email, + FullName: fullName, + } + + if err := user.SetPassword(password); err != nil { + return c.Status(400).JSON(GraphQLResponse{ + Errors: []GraphQLError{{Message: err.Error()}}, + }) + } + + // Call service + createdUser, err := h.membershipService.CreateUser(ctx, user, []string{"user"}) + if err != nil { + return c.Status(400).JSON(GraphQLResponse{ + Errors: []GraphQLError{{Message: err.Error()}}, + }) + } + + // Convert user to response format + userData := map[string]interface{}{ + "id": createdUser.ID, + "email": createdUser.Email, + "fullName": createdUser.FullName, + "createdAt": createdUser.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + "updatedAt": createdUser.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + } + + response := map[string]interface{}{ + "createUser": userData, + } + + return c.JSON(GraphQLResponse{Data: response}) +} + +// handleMe handles me queries +func (h *GraphQLHandler) handleMe(c *fiber.Ctx, ctx context.Context, req GraphQLRequest) error { + // This would typically extract user ID from JWT token + // For now, return mock data + userData := map[string]interface{}{ + "id": "1", + "email": "admin@example.com", + "fullName": "System Administrator", + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z", + } + + response := map[string]interface{}{ + "me": userData, + } + + return c.JSON(GraphQLResponse{Data: response}) +} + +// handleUsers handles users queries +func (h *GraphQLHandler) handleUsers(c *fiber.Ctx, ctx context.Context, req GraphQLRequest) error { + // Call service + users, err := h.membershipService.ListUsers(ctx) + if err != nil { + return c.Status(500).JSON(GraphQLResponse{ + Errors: []GraphQLError{{Message: "Failed to fetch users"}}, + }) + } + + // Convert users to response format + usersData := make([]map[string]interface{}, len(users)) + for i, user := range users { + usersData[i] = map[string]interface{}{ + "id": user.ID, + "email": user.Email, + "fullName": user.FullName, + "createdAt": user.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + "updatedAt": user.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + } + } + + response := map[string]interface{}{ + "users": usersData, + } + + return c.JSON(GraphQLResponse{Data: response}) +} + +// handleCreateProject handles project creation mutations +func (h *GraphQLHandler) handleCreateProject(c *fiber.Ctx, ctx context.Context, req GraphQLRequest) error { + // Extract variables + name, ok := req.Variables["name"].(string) + if !ok { + return c.Status(400).JSON(GraphQLResponse{ + Errors: []GraphQLError{{Message: "Name is required"}}, + }) + } + + ownerId, ok := req.Variables["ownerId"].(string) + if !ok { + return c.Status(400).JSON(GraphQLResponse{ + Errors: []GraphQLError{{Message: "Owner ID is required"}}, + }) + } + + description, _ := req.Variables["description"].(string) + + // Mock project data for now + projectData := map[string]interface{}{ + "id": "mock-project-id", + "name": name, + "description": description, + "ownerId": ownerId, + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z", + } + + response := map[string]interface{}{ + "createProject": projectData, + } + + return c.JSON(GraphQLResponse{Data: response}) +} + +// handleCreateTask handles task creation mutations +func (h *GraphQLHandler) handleCreateTask(c *fiber.Ctx, ctx context.Context, req GraphQLRequest) error { + // Extract variables + title, ok := req.Variables["title"].(string) + if !ok { + return c.Status(400).JSON(GraphQLResponse{ + Errors: []GraphQLError{{Message: "Title is required"}}, + }) + } + + projectId, ok := req.Variables["projectId"].(string) + if !ok { + return c.Status(400).JSON(GraphQLResponse{ + Errors: []GraphQLError{{Message: "Project ID is required"}}, + }) + } + + description, _ := req.Variables["description"].(string) + status, _ := req.Variables["status"].(string) + if status == "" { + status = "todo" + } + priority, _ := req.Variables["priority"].(string) + if priority == "" { + priority = "medium" + } + + // Mock task data for now + taskData := map[string]interface{}{ + "id": "mock-task-id", + "title": title, + "description": description, + "status": status, + "priority": priority, + "projectId": projectId, + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z", + } + + response := map[string]interface{}{ + "createTask": taskData, + } + + return c.JSON(GraphQLResponse{Data: response}) +} + +// handleProjects handles projects queries +func (h *GraphQLHandler) handleProjects(c *fiber.Ctx, ctx context.Context, req GraphQLRequest) error { + // Mock empty projects list for now + response := map[string]interface{}{ + "projects": []interface{}{}, + } + + return c.JSON(GraphQLResponse{Data: response}) +} + +// handleTasks handles tasks queries +func (h *GraphQLHandler) handleTasks(c *fiber.Ctx, ctx context.Context, req GraphQLRequest) error { + // Mock empty tasks list for now + response := map[string]interface{}{ + "tasks": []interface{}{}, + } + + return c.JSON(GraphQLResponse{Data: response}) +} + +// GetSchema returns the GraphQL schema +func (h *GraphQLHandler) GetSchema() string { + return ` +# Core Types +type User { + id: String! + email: String! + fullName: String + createdAt: String! + updatedAt: String! +} + +type Project { + id: String! + name: String! + description: String + ownerId: String! + createdAt: String! + updatedAt: String! +} + +type Task { + id: String! + title: String! + description: String + status: String! + priority: String! + projectId: String! + createdAt: String! + updatedAt: String! +} + +# Input Types +input LoginInput { + email: String! + password: String! +} + +input UserCreateInput { + email: String! + fullName: String! + password: String! +} + +input ProjectCreateInput { + name: String! + description: String + ownerId: String! +} + +input TaskCreateInput { + title: String! + description: String + status: String + priority: String + projectId: String! +} + +# Response Types +type AuthResponse { + token: String! + user: User! +} + +type MessageResponse { + message: String! + success: Boolean! +} + +# Queries +type Query { + me: User! + users: [User!]! + user(id: String!): User + projects: [Project!]! + project(id: String!): Project + tasks(projectId: String): [Task!]! + task(id: String!): Task +} + +# Mutations +type Mutation { + login(input: LoginInput!): AuthResponse! + createUser(input: UserCreateInput!): User! + createProject(input: ProjectCreateInput!): Project! + createTask(input: TaskCreateInput!): Task! +} +` +} diff --git a/local/graphql/schema/schema.graphql b/local/graphql/schema/schema.graphql new file mode 100644 index 0000000..a8ed86e --- /dev/null +++ b/local/graphql/schema/schema.graphql @@ -0,0 +1,100 @@ +# Minimal GraphQL Schema for Phase 1 + +# Core Types +type User { + id: String! + email: String! + fullName: String + createdAt: String! + updatedAt: String! +} + +type Project { + id: String! + name: String! + description: String + ownerId: String! + createdAt: String! + updatedAt: String! +} + +type Task { + id: String! + title: String! + description: String + status: String! + priority: String! + projectId: String! + createdAt: String! + updatedAt: String! +} + +# Input Types +input LoginInput { + email: String! + password: String! +} + +input UserCreateInput { + email: String! + fullName: String! + password: String! +} + +input ProjectCreateInput { + name: String! + description: String + ownerId: String! +} + +input TaskCreateInput { + title: String! + description: String + status: String + priority: String + projectId: String! +} + +# Response Types +type AuthResponse { + token: String! + user: User! +} + +type MessageResponse { + message: String! + success: Boolean! +} + +# Queries +type Query { + # Authentication + me: User! + + # Users + users: [User!]! + user(id: String!): User + + # Projects + projects: [Project!]! + project(id: String!): Project + + # Tasks + tasks(projectId: String): [Task!]! + task(id: String!): Task +} + +# Mutations +type Mutation { + # Authentication + login(input: LoginInput!): AuthResponse! + + # Users + createUser(input: UserCreateInput!): User! + + # Projects + createProject(input: ProjectCreateInput!): Project! + + # Tasks + createTask(input: TaskCreateInput!): Task! +} diff --git a/local/graphql/service/service.go b/local/graphql/service/service.go new file mode 100644 index 0000000..9f5e1a9 --- /dev/null +++ b/local/graphql/service/service.go @@ -0,0 +1,173 @@ +package service + +import ( + "context" + "omega-server/local/model" + "omega-server/local/service" +) + +// GraphQLService provides GraphQL-specific business logic +type GraphQLService struct { + membershipService *service.MembershipService +} + +// NewGraphQLService creates a new GraphQL service +func NewGraphQLService(membershipService *service.MembershipService) *GraphQLService { + return &GraphQLService{ + membershipService: membershipService, + } +} + +// AuthResponse represents authentication response +type AuthResponse struct { + Token string `json:"token"` + User *model.User `json:"user"` +} + +// MessageResponse represents a generic message response +type MessageResponse struct { + Message string `json:"message"` + Success bool `json:"success"` +} + +// Login handles user authentication +func (s *GraphQLService) Login(ctx context.Context, email, password string) (*AuthResponse, error) { + token, err := s.membershipService.Login(ctx, email, password) + if err != nil { + return nil, err + } + + // For now, return a mock user. In a full implementation, we'd get the user from the token + user := &model.User{ + BaseModel: model.BaseModel{ + ID: "mock-user-id", + }, + Email: email, + FullName: "Mock User", + } + + return &AuthResponse{ + Token: token, + User: user, + }, nil +} + +// CreateUser handles user creation +func (s *GraphQLService) CreateUser(ctx context.Context, email, password, fullName string) (*model.User, error) { + // Create domain model + user := &model.User{ + Email: email, + FullName: fullName, + } + + if err := user.SetPassword(password); err != nil { + return nil, err + } + + return s.membershipService.CreateUser(ctx, user, []string{"user"}) +} + +// GetUsers retrieves all users +func (s *GraphQLService) GetUsers(ctx context.Context) ([]*model.User, error) { + return s.membershipService.ListUsers(ctx) +} + +// GetUser retrieves a specific user by ID +func (s *GraphQLService) GetUser(ctx context.Context, id string) (*model.User, error) { + // This would need to be implemented in the membership service + // For now, return a mock user + return &model.User{ + BaseModel: model.BaseModel{ + ID: id, + }, + Email: "mock@example.com", + FullName: "Mock User", + }, nil +} + +// GetMe retrieves the current authenticated user +func (s *GraphQLService) GetMe(ctx context.Context, userID string) (*model.User, error) { + // This would typically extract user ID from JWT token + // For now, return a mock user + return &model.User{ + BaseModel: model.BaseModel{ + ID: userID, + }, + Email: "current@example.com", + FullName: "Current User", + }, nil +} + +// CreateProject handles project creation +func (s *GraphQLService) CreateProject(ctx context.Context, name, description, ownerID string) (*model.Project, error) { + // This would need to be implemented when we have project service + // For now, return a mock project + return &model.Project{ + BaseModel: model.BaseModel{ + ID: "mock-project-id", + }, + Name: name, + Description: description, + OwnerID: ownerID, + }, nil +} + +// GetProjects retrieves all projects +func (s *GraphQLService) GetProjects(ctx context.Context) ([]*model.Project, error) { + // This would need to be implemented when we have project service + // For now, return empty slice + return []*model.Project{}, nil +} + +// GetProject retrieves a specific project by ID +func (s *GraphQLService) GetProject(ctx context.Context, id string) (*model.Project, error) { + // This would need to be implemented when we have project service + // For now, return a mock project + return &model.Project{ + BaseModel: model.BaseModel{ + ID: id, + }, + Name: "Mock Project", + Description: "Mock project description", + OwnerID: "mock-owner-id", + }, nil +} + +// CreateTask handles task creation +func (s *GraphQLService) CreateTask(ctx context.Context, title, description, status, priority, projectID string) (*model.Task, error) { + // This would need to be implemented when we have task service + // For now, return a mock task + return &model.Task{ + BaseModel: model.BaseModel{ + ID: "mock-task-id", + }, + Title: title, + Description: description, + Status: model.TaskStatus(status), + Priority: model.TaskPriority(priority), + ProjectID: projectID, + }, nil +} + +// GetTasks retrieves tasks, optionally filtered by project ID +func (s *GraphQLService) GetTasks(ctx context.Context, projectID *string) ([]*model.Task, error) { + // This would need to be implemented when we have task service + // For now, return empty slice + return []*model.Task{}, nil +} + +// GetTask retrieves a specific task by ID +func (s *GraphQLService) GetTask(ctx context.Context, id string) (*model.Task, error) { + // This would need to be implemented when we have task service + // For now, return a mock task + return &model.Task{ + BaseModel: model.BaseModel{ + ID: id, + }, + Title: "Mock Task", + Description: "Mock task description", + Status: model.TaskStatusTodo, + Priority: model.TaskPriorityMedium, + ProjectID: "mock-project-id", + }, nil +} diff --git a/local/middleware/auth.go b/local/middleware/auth.go index 9fabe6d..157755d 100644 --- a/local/middleware/auth.go +++ b/local/middleware/auth.go @@ -26,13 +26,13 @@ type CachedUserInfo struct { // AuthMiddleware provides authentication and permission middleware. type AuthMiddleware struct { - membershipService service.MembershipServiceInterface + membershipService *service.MembershipService cache *cache.InMemoryCache securityMW *security.SecurityMiddleware } // NewAuthMiddleware creates a new AuthMiddleware. -func NewAuthMiddleware(ms service.MembershipServiceInterface, cache *cache.InMemoryCache) *AuthMiddleware { +func NewAuthMiddleware(ms *service.MembershipService, cache *cache.InMemoryCache) *AuthMiddleware { auth := &AuthMiddleware{ membershipService: ms, cache: cache, @@ -201,7 +201,7 @@ func (m *AuthMiddleware) getCachedUserInfo(ctx context.Context, userID string) ( userInfo := &CachedUserInfo{ UserID: userID, - Username: user.Username, + Username: user.FullName, Roles: roleNames, RoleNames: roleNames, Permissions: permissions, diff --git a/local/model/audit_log.go b/local/model/audit_log.go index 8f49eda..97d67fa 100644 --- a/local/model/audit_log.go +++ b/local/model/audit_log.go @@ -44,22 +44,22 @@ type AuditLogCreateRequest struct { // AuditLogInfo represents public audit log information type AuditLogInfo struct { - ID string `json:"id"` - UserID string `json:"userId"` - UserEmail string `json:"userEmail,omitempty"` - UserName string `json:"userName,omitempty"` - Action string `json:"action"` - Resource string `json:"resource"` - ResourceID string `json:"resourceId"` - Details map[string]interface{} `json:"details"` - IPAddress string `json:"ipAddress"` - UserAgent string `json:"userAgent"` - Success bool `json:"success"` - ErrorMsg string `json:"errorMsg,omitempty"` - Duration int64 `json:"duration,omitempty"` - SessionID string `json:"sessionId,omitempty"` - RequestID string `json:"requestId,omitempty"` - DateCreated string `json:"dateCreated"` + ID string `json:"id"` + UserID string `json:"userId"` + UserEmail string `json:"userEmail,omitempty"` + UserName string `json:"userName,omitempty"` + Action string `json:"action"` + Resource string `json:"resource"` + ResourceID string `json:"resourceId"` + Details map[string]interface{} `json:"details"` + IPAddress string `json:"ipAddress"` + UserAgent string `json:"userAgent"` + Success bool `json:"success"` + ErrorMsg string `json:"errorMsg,omitempty"` + Duration int64 `json:"duration,omitempty"` + SessionID string `json:"sessionId,omitempty"` + RequestID string `json:"requestId,omitempty"` + CreatedAt string `json:"created_at"` } // BeforeCreate is called before creating an audit log @@ -122,26 +122,26 @@ func (al *AuditLog) GetDetailsAsJSON() (string, error) { // ToAuditLogInfo converts AuditLog to AuditLogInfo (public information) func (al *AuditLog) ToAuditLogInfo() AuditLogInfo { info := AuditLogInfo{ - ID: al.ID, - UserID: al.UserID, - Action: al.Action, - Resource: al.Resource, - ResourceID: al.ResourceID, - Details: al.GetDetails(), - IPAddress: al.IPAddress, - UserAgent: al.UserAgent, - Success: al.Success, - ErrorMsg: al.ErrorMsg, - Duration: al.Duration, - SessionID: al.SessionID, - RequestID: al.RequestID, - DateCreated: al.DateCreated.Format("2006-01-02T15:04:05Z"), + ID: al.ID, + UserID: al.UserID, + Action: al.Action, + Resource: al.Resource, + ResourceID: al.ResourceID, + Details: al.GetDetails(), + IPAddress: al.IPAddress, + UserAgent: al.UserAgent, + Success: al.Success, + ErrorMsg: al.ErrorMsg, + Duration: al.Duration, + SessionID: al.SessionID, + RequestID: al.RequestID, + CreatedAt: al.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), } // Include user information if available if al.User != nil { info.UserEmail = al.User.Email - info.UserName = al.User.Name + info.UserName = al.User.FullName } return info diff --git a/local/model/base.go b/local/model/base.go index 9c76a34..6728476 100644 --- a/local/model/base.go +++ b/local/model/base.go @@ -8,22 +8,22 @@ import ( // BaseModel provides common fields for all database models type BaseModel struct { - ID string `json:"id" gorm:"primary_key;type:varchar(36)"` - DateCreated time.Time `json:"dateCreated" gorm:"not null"` - DateUpdated time.Time `json:"dateUpdated" gorm:"not null"` + ID string `json:"id" gorm:"type:uuid;primary_key;default:gen_random_uuid()"` + CreatedAt time.Time `json:"created_at" gorm:"not null;default:now()"` + UpdatedAt time.Time `json:"updated_at" gorm:"not null;default:now()"` } -// Init initializes base model with DateCreated, DateUpdated, and ID values +// Init initializes base model with CreatedAt, UpdatedAt, and ID values func (bm *BaseModel) Init() { now := time.Now().UTC() bm.ID = uuid.NewString() - bm.DateCreated = now - bm.DateUpdated = now + bm.CreatedAt = now + bm.UpdatedAt = now } -// UpdateTimestamp updates the DateUpdated field +// UpdateTimestamp updates the UpdatedAt field func (bm *BaseModel) UpdateTimestamp() { - bm.DateUpdated = time.Now().UTC() + bm.UpdatedAt = time.Now().UTC() } // BeforeCreate is a GORM hook that runs before creating a record @@ -76,7 +76,7 @@ func DefaultParams() Params { return Params{ Page: 1, Limit: 10, - SortBy: "dateCreated", + SortBy: "created_at", SortOrder: "desc", } } @@ -90,7 +90,7 @@ func (p *Params) Validate() { p.Limit = 10 } if p.SortBy == "" { - p.SortBy = "dateCreated" + p.SortBy = "created_at" } if p.SortOrder != "asc" && p.SortOrder != "desc" { p.SortOrder = "desc" diff --git a/local/model/integration.go b/local/model/integration.go new file mode 100644 index 0000000..9ab49f9 --- /dev/null +++ b/local/model/integration.go @@ -0,0 +1,144 @@ +package model + +import ( + "encoding/json" + "errors" + "strings" + + "gorm.io/gorm" +) + +// Integration represents a third-party integration configuration +type Integration struct { + BaseModel + ProjectID string `json:"project_id" gorm:"not null;type:uuid;index;references:projects(id);onDelete:CASCADE"` + Type string `json:"type" gorm:"not null;type:varchar(50)"` + Config json.RawMessage `json:"config" gorm:"type:jsonb;not null"` + Project Project `json:"project,omitempty" gorm:"foreignKey:ProjectID"` +} + +// IntegrationCreateRequest represents the request to create a new integration +type IntegrationCreateRequest struct { + ProjectID string `json:"project_id" validate:"required,uuid"` + Type string `json:"type" validate:"required,min=1,max=50"` + Config map[string]interface{} `json:"config" validate:"required"` +} + +// IntegrationUpdateRequest represents the request to update an integration +type IntegrationUpdateRequest struct { + Config map[string]interface{} `json:"config" validate:"required"` +} + +// IntegrationInfo represents public integration information +type IntegrationInfo struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + Type string `json:"type"` + Config map[string]interface{} `json:"config"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// BeforeCreate is called before creating an integration +func (i *Integration) BeforeCreate(tx *gorm.DB) error { + i.BaseModel.BeforeCreate() + + // Normalize type + i.Type = strings.ToLower(strings.TrimSpace(i.Type)) + + return i.Validate() +} + +// BeforeUpdate is called before updating an integration +func (i *Integration) BeforeUpdate(tx *gorm.DB) error { + i.BaseModel.BeforeUpdate() + + // Normalize type if it's being updated + if i.Type != "" { + i.Type = strings.ToLower(strings.TrimSpace(i.Type)) + } + + return i.Validate() +} + +// Validate validates integration data +func (i *Integration) Validate() error { + if i.ProjectID == "" { + return errors.New("project_id is required") + } + + if i.Type == "" { + return errors.New("type is required") + } + + if len(i.Type) > 50 { + return errors.New("type must not exceed 50 characters") + } + + if len(i.Config) == 0 { + return errors.New("config is required") + } + + // Validate that config is valid JSON + var configMap map[string]interface{} + if err := json.Unmarshal(i.Config, &configMap); err != nil { + return errors.New("config must be valid JSON") + } + + return nil +} + +// ToIntegrationInfo converts Integration to IntegrationInfo (public information) +func (i *Integration) ToIntegrationInfo() IntegrationInfo { + integrationInfo := IntegrationInfo{ + ID: i.ID, + ProjectID: i.ProjectID, + Type: i.Type, + CreatedAt: i.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + UpdatedAt: i.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + } + + // Parse config JSON to map + var configMap map[string]interface{} + if err := json.Unmarshal(i.Config, &configMap); err == nil { + integrationInfo.Config = configMap + } + + return integrationInfo +} + +// SetConfig sets the configuration from a map +func (i *Integration) SetConfig(config map[string]interface{}) error { + configJSON, err := json.Marshal(config) + if err != nil { + return err + } + i.Config = configJSON + return nil +} + +// GetConfig gets the configuration as a map +func (i *Integration) GetConfig() (map[string]interface{}, error) { + var configMap map[string]interface{} + err := json.Unmarshal(i.Config, &configMap) + return configMap, err +} + +// GetConfigValue gets a specific configuration value +func (i *Integration) GetConfigValue(key string) (interface{}, error) { + configMap, err := i.GetConfig() + if err != nil { + return nil, err + } + return configMap[key], nil +} + +// SetConfigValue sets a specific configuration value +func (i *Integration) SetConfigValue(key string, value interface{}) error { + configMap, err := i.GetConfig() + if err != nil { + return err + } + configMap[key] = value + return i.SetConfig(configMap) +} diff --git a/local/model/membership_filter.go b/local/model/membership_filter.go index 7eddf88..c471b5f 100644 --- a/local/model/membership_filter.go +++ b/local/model/membership_filter.go @@ -156,10 +156,10 @@ func (f *MembershipFilter) GetSorting() (field string, desc bool) { // Map common sort fields to database column names switch f.SortBy { - case "dateCreated": - field = "date_created" - case "dateUpdated": - field = "date_updated" + case "created_at": + field = "created_at" + case "updated_at": + field = "updated_at" case "username": field = "username" case "email": diff --git a/local/model/permission.go b/local/model/permission.go index b6e1717..624598d 100644 --- a/local/model/permission.go +++ b/local/model/permission.go @@ -42,7 +42,7 @@ type PermissionInfo struct { Active bool `json:"active"` System bool `json:"system"` RoleCount int64 `json:"roleCount"` - DateCreated string `json:"dateCreated"` + CreatedAt string `json:"created_at"` } // BeforeCreate is called before creating a permission @@ -132,7 +132,7 @@ func (p *Permission) ToPermissionInfo() PermissionInfo { Category: p.Category, Active: p.Active, System: p.System, - DateCreated: p.DateCreated.Format("2006-01-02T15:04:05Z"), + CreatedAt: p.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), } } diff --git a/local/model/project.go b/local/model/project.go new file mode 100644 index 0000000..f335e31 --- /dev/null +++ b/local/model/project.go @@ -0,0 +1,148 @@ +package model + +import ( + "errors" + "strings" + + "gorm.io/gorm" +) + +// Project represents a project in the system +type Project struct { + BaseModel + Name string `json:"name" gorm:"not null;type:varchar(255)"` + Description string `json:"description" gorm:"type:text"` + OwnerID string `json:"owner_id" gorm:"not null;type:uuid;index;references:users(id)"` + TypeID string `json:"type_id" gorm:"not null;type:uuid;index;references:types(id)"` + Owner User `json:"owner,omitempty" gorm:"foreignKey:OwnerID"` + Type Type `json:"type,omitempty" gorm:"foreignKey:TypeID"` + Tasks []Task `json:"tasks,omitempty" gorm:"foreignKey:ProjectID"` + Members []User `json:"members,omitempty" gorm:"many2many:project_members;"` +} + +// ProjectCreateRequest represents the request to create a new project +type ProjectCreateRequest struct { + Name string `json:"name" validate:"required,min=1,max=255"` + Description string `json:"description" validate:"max=1000"` + OwnerID string `json:"owner_id" validate:"required,uuid"` + TypeID string `json:"type_id" validate:"required,uuid"` +} + +// ProjectUpdateRequest represents the request to update a project +type ProjectUpdateRequest struct { + Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"` + Description *string `json:"description,omitempty" validate:"omitempty,max=1000"` + TypeID *string `json:"type_id,omitempty" validate:"omitempty,uuid"` +} + +// ProjectInfo represents public project information +type ProjectInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + OwnerID string `json:"owner_id"` + TypeID string `json:"type_id"` + Owner UserInfo `json:"owner,omitempty"` + Type TypeInfo `json:"type,omitempty"` + TaskCount int64 `json:"task_count"` + MemberCount int64 `json:"member_count"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// ProjectMember represents the many-to-many relationship between projects and users +type ProjectMember struct { + ProjectID string `json:"project_id" gorm:"type:uuid;primaryKey"` + UserID string `json:"user_id" gorm:"type:uuid;primaryKey"` + RoleID string `json:"role_id" gorm:"type:uuid;not null;references:roles(id)"` + Project Project `json:"project,omitempty" gorm:"foreignKey:ProjectID"` + User User `json:"user,omitempty" gorm:"foreignKey:UserID"` + Role Role `json:"role,omitempty" gorm:"foreignKey:RoleID"` +} + +// BeforeCreate is called before creating a project +func (p *Project) BeforeCreate(tx *gorm.DB) error { + p.BaseModel.BeforeCreate() + + // Normalize name and description + p.Name = strings.TrimSpace(p.Name) + p.Description = strings.TrimSpace(p.Description) + + return p.Validate() +} + +// BeforeUpdate is called before updating a project +func (p *Project) BeforeUpdate(tx *gorm.DB) error { + p.BaseModel.BeforeUpdate() + + // Normalize fields if they're being updated + if p.Name != "" { + p.Name = strings.TrimSpace(p.Name) + } + if p.Description != "" { + p.Description = strings.TrimSpace(p.Description) + } + + return p.Validate() +} + +// Validate validates project data +func (p *Project) Validate() error { + if p.Name == "" { + return errors.New("name is required") + } + + if len(p.Name) > 255 { + return errors.New("name must not exceed 255 characters") + } + + if p.OwnerID == "" { + return errors.New("owner_id is required") + } + + if p.TypeID == "" { + return errors.New("type_id is required") + } + + return nil +} + +// ToProjectInfo converts Project to ProjectInfo (public information) +func (p *Project) ToProjectInfo() ProjectInfo { + projectInfo := ProjectInfo{ + ID: p.ID, + Name: p.Name, + Description: p.Description, + OwnerID: p.OwnerID, + TypeID: p.TypeID, + CreatedAt: p.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + UpdatedAt: p.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + } + + // Add owner info if loaded + if p.Owner.ID != "" { + projectInfo.Owner = p.Owner.ToUserInfo() + } + + // Add type info if loaded + if p.Type.ID != "" { + projectInfo.Type = p.Type.ToTypeInfo() + } + + return projectInfo +} + +// HasMember checks if a user is a member of the project +func (p *Project) HasMember(userID string) bool { + for _, member := range p.Members { + if member.ID == userID { + return true + } + } + return false +} + +// IsOwner checks if a user is the owner of the project +func (p *Project) IsOwner(userID string) bool { + return p.OwnerID == userID +} diff --git a/local/model/role.go b/local/model/role.go index 79f7557..0593731 100644 --- a/local/model/role.go +++ b/local/model/role.go @@ -42,7 +42,7 @@ type RoleInfo struct { System bool `json:"system"` Permissions []PermissionInfo `json:"permissions"` UserCount int64 `json:"userCount"` - DateCreated string `json:"dateCreated"` + CreatedAt string `json:"created_at"` } // BeforeCreate is called before creating a role @@ -120,7 +120,7 @@ func (r *Role) ToRoleInfo() RoleInfo { Active: r.Active, System: r.System, Permissions: make([]PermissionInfo, len(r.Permissions)), - DateCreated: r.DateCreated.Format("2006-01-02T15:04:05Z"), + CreatedAt: r.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), } // Convert permissions diff --git a/local/model/security_event.go b/local/model/security_event.go index 197392e..ef9e3d6 100644 --- a/local/model/security_event.go +++ b/local/model/security_event.go @@ -86,7 +86,7 @@ type SecurityEventInfo struct { ResolverName string `json:"resolverName,omitempty"` ResolvedAt *time.Time `json:"resolvedAt,omitempty"` Notes string `json:"notes,omitempty"` - DateCreated string `json:"dateCreated"` + CreatedAt string `json:"created_at"` } // BeforeCreate is called before creating a security event @@ -202,19 +202,19 @@ func (se *SecurityEvent) ToSecurityEventInfo() SecurityEventInfo { ResolvedBy: se.ResolvedBy, ResolvedAt: se.ResolvedAt, Notes: se.Notes, - DateCreated: se.DateCreated.Format("2006-01-02T15:04:05Z"), + CreatedAt: se.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), } // Include user information if available if se.User != nil { info.UserEmail = se.User.Email - info.UserName = se.User.Name + info.UserName = se.User.FullName } // Include resolver information if available if se.Resolver != nil { info.ResolverEmail = se.Resolver.Email - info.ResolverName = se.Resolver.Name + info.ResolverName = se.Resolver.FullName } return info diff --git a/local/model/system_config.go b/local/model/system_config.go index 8cfcf29..8e01f24 100644 --- a/local/model/system_config.go +++ b/local/model/system_config.go @@ -56,7 +56,7 @@ type SystemConfigInfo struct { DataType string `json:"dataType"` IsEditable bool `json:"isEditable"` IsSecret bool `json:"isSecret"` - DateCreated string `json:"dateCreated"` + CreatedAt string `json:"created_at"` DateModified string `json:"dateModified"` } @@ -170,7 +170,7 @@ func (sc *SystemConfig) ToSystemConfigInfo() SystemConfigInfo { DataType: sc.DataType, IsEditable: sc.IsEditable, IsSecret: sc.IsSecret, - DateCreated: sc.DateCreated.Format("2006-01-02T15:04:05Z"), + CreatedAt: sc.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), DateModified: sc.DateModified, } diff --git a/local/model/task.go b/local/model/task.go new file mode 100644 index 0000000..bf6008f --- /dev/null +++ b/local/model/task.go @@ -0,0 +1,213 @@ +package model + +import ( + "errors" + "strings" + + "gorm.io/gorm" +) + +// TaskStatus represents the status of a task +type TaskStatus string + +const ( + TaskStatusTodo TaskStatus = "todo" + TaskStatusInProgress TaskStatus = "in_progress" + TaskStatusDone TaskStatus = "done" + TaskStatusCanceled TaskStatus = "canceled" +) + +// TaskPriority represents the priority of a task +type TaskPriority string + +const ( + TaskPriorityLow TaskPriority = "low" + TaskPriorityMedium TaskPriority = "medium" + TaskPriorityHigh TaskPriority = "high" +) + +// Task represents a task in the system +type Task struct { + BaseModel + Title string `json:"title" gorm:"not null;type:varchar(255)"` + Description string `json:"description" gorm:"type:text"` + Status TaskStatus `json:"status" gorm:"not null;default:'todo';type:varchar(20)"` + Priority TaskPriority `json:"priority" gorm:"not null;default:'medium';type:varchar(20)"` + ProjectID string `json:"project_id" gorm:"not null;type:uuid;index;references:projects(id);onDelete:CASCADE"` + DueDate *string `json:"due_date" gorm:"type:date"` + Project Project `json:"project,omitempty" gorm:"foreignKey:ProjectID"` + Assignees []User `json:"assignees,omitempty" gorm:"many2many:task_assignees;"` +} + +// TaskCreateRequest represents the request to create a new task +type TaskCreateRequest struct { + Title string `json:"title" validate:"required,min=1,max=255"` + Description string `json:"description" validate:"max=1000"` + Status TaskStatus `json:"status" validate:"omitempty,oneof=todo in_progress done canceled"` + Priority TaskPriority `json:"priority" validate:"omitempty,oneof=low medium high"` + ProjectID string `json:"project_id" validate:"required,uuid"` + DueDate *string `json:"due_date"` + AssigneeIDs []string `json:"assignee_ids"` +} + +// TaskUpdateRequest represents the request to update a task +type TaskUpdateRequest struct { + Title *string `json:"title,omitempty" validate:"omitempty,min=1,max=255"` + Description *string `json:"description,omitempty" validate:"omitempty,max=1000"` + Status *TaskStatus `json:"status,omitempty" validate:"omitempty,oneof=todo in_progress done canceled"` + Priority *TaskPriority `json:"priority,omitempty" validate:"omitempty,oneof=low medium high"` + DueDate *string `json:"due_date,omitempty"` + AssigneeIDs []string `json:"assignee_ids,omitempty"` +} + +// TaskInfo represents public task information +type TaskInfo struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Status TaskStatus `json:"status"` + Priority TaskPriority `json:"priority"` + ProjectID string `json:"project_id"` + DueDate *string `json:"due_date"` + Project ProjectInfo `json:"project,omitempty"` + Assignees []UserInfo `json:"assignees,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// TaskAssignee represents the many-to-many relationship between tasks and users +type TaskAssignee struct { + TaskID string `json:"task_id" gorm:"type:uuid;primaryKey"` + UserID string `json:"user_id" gorm:"type:uuid;primaryKey"` + Task Task `json:"task,omitempty" gorm:"foreignKey:TaskID"` + User User `json:"user,omitempty" gorm:"foreignKey:UserID"` +} + +// BeforeCreate is called before creating a task +func (t *Task) BeforeCreate(tx *gorm.DB) error { + t.BaseModel.BeforeCreate() + + // Normalize title and description + t.Title = strings.TrimSpace(t.Title) + t.Description = strings.TrimSpace(t.Description) + + // Set default values + if t.Status == "" { + t.Status = TaskStatusTodo + } + if t.Priority == "" { + t.Priority = TaskPriorityMedium + } + + return t.Validate() +} + +// BeforeUpdate is called before updating a task +func (t *Task) BeforeUpdate(tx *gorm.DB) error { + t.BaseModel.BeforeUpdate() + + // Normalize fields if they're being updated + if t.Title != "" { + t.Title = strings.TrimSpace(t.Title) + } + if t.Description != "" { + t.Description = strings.TrimSpace(t.Description) + } + + return t.Validate() +} + +// Validate validates task data +func (t *Task) Validate() error { + if t.Title == "" { + return errors.New("title is required") + } + + if len(t.Title) > 255 { + return errors.New("title must not exceed 255 characters") + } + + if t.ProjectID == "" { + return errors.New("project_id is required") + } + + // Validate status + if t.Status != "" { + switch t.Status { + case TaskStatusTodo, TaskStatusInProgress, TaskStatusDone, TaskStatusCanceled: + // Valid status + default: + return errors.New("invalid status") + } + } + + // Validate priority + if t.Priority != "" { + switch t.Priority { + case TaskPriorityLow, TaskPriorityMedium, TaskPriorityHigh: + // Valid priority + default: + return errors.New("invalid priority") + } + } + + return nil +} + +// ToTaskInfo converts Task to TaskInfo (public information) +func (t *Task) ToTaskInfo() TaskInfo { + taskInfo := TaskInfo{ + ID: t.ID, + Title: t.Title, + Description: t.Description, + Status: t.Status, + Priority: t.Priority, + ProjectID: t.ProjectID, + DueDate: t.DueDate, + CreatedAt: t.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + UpdatedAt: t.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + Assignees: make([]UserInfo, len(t.Assignees)), + } + + // Add project info if loaded + if t.Project.ID != "" { + taskInfo.Project = t.Project.ToProjectInfo() + } + + // Add assignee info if loaded + for i, assignee := range t.Assignees { + taskInfo.Assignees[i] = assignee.ToUserInfo() + } + + return taskInfo +} + +// IsAssignedTo checks if a user is assigned to this task +func (t *Task) IsAssignedTo(userID string) bool { + for _, assignee := range t.Assignees { + if assignee.ID == userID { + return true + } + } + return false +} + +// IsCompleted checks if the task is completed +func (t *Task) IsCompleted() bool { + return t.Status == TaskStatusDone +} + +// IsCanceled checks if the task is canceled +func (t *Task) IsCanceled() bool { + return t.Status == TaskStatusCanceled +} + +// IsInProgress checks if the task is in progress +func (t *Task) IsInProgress() bool { + return t.Status == TaskStatusInProgress +} + +// IsTodo checks if the task is todo +func (t *Task) IsTodo() bool { + return t.Status == TaskStatusTodo +} diff --git a/local/model/type.go b/local/model/type.go new file mode 100644 index 0000000..02f5843 --- /dev/null +++ b/local/model/type.go @@ -0,0 +1,95 @@ +package model + +import ( + "errors" + "strings" + + "gorm.io/gorm" +) + +// Type represents a project type in the system +type Type struct { + BaseModel + UserID *string `json:"user_id" gorm:"type:uuid;index;references:users(id);onDelete:SET NULL"` + Name string `json:"name" gorm:"not null;type:varchar(100)"` + Description string `json:"description" gorm:"type:text"` + User *User `json:"user,omitempty" gorm:"foreignKey:UserID"` +} + +// TypeCreateRequest represents the request to create a new type +type TypeCreateRequest struct { + UserID *string `json:"user_id"` + Name string `json:"name" validate:"required,min=1,max=100"` + Description string `json:"description" validate:"max=1000"` +} + +// TypeUpdateRequest represents the request to update a type +type TypeUpdateRequest struct { + Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=100"` + Description *string `json:"description,omitempty" validate:"omitempty,max=1000"` +} + +// TypeInfo represents public type information +type TypeInfo struct { + ID string `json:"id"` + UserID *string `json:"user_id"` + Name string `json:"name"` + Description string `json:"description"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// BeforeCreate is called before creating a type +func (t *Type) BeforeCreate(tx *gorm.DB) error { + t.BaseModel.BeforeCreate() + + // Normalize name + t.Name = strings.TrimSpace(t.Name) + t.Description = strings.TrimSpace(t.Description) + + return t.Validate() +} + +// BeforeUpdate is called before updating a type +func (t *Type) BeforeUpdate(tx *gorm.DB) error { + t.BaseModel.BeforeUpdate() + + // Normalize fields if they're being updated + if t.Name != "" { + t.Name = strings.TrimSpace(t.Name) + } + if t.Description != "" { + t.Description = strings.TrimSpace(t.Description) + } + + return t.Validate() +} + +// Validate validates type data +func (t *Type) Validate() error { + if t.Name == "" { + return errors.New("name is required") + } + + if len(t.Name) > 100 { + return errors.New("name must not exceed 100 characters") + } + + if len(t.Description) > 1000 { + return errors.New("description must not exceed 1000 characters") + } + + return nil +} + +// ToTypeInfo converts Type to TypeInfo (public information) +func (t *Type) ToTypeInfo() TypeInfo { + return TypeInfo{ + ID: t.ID, + UserID: t.UserID, + Name: t.Name, + Description: t.Description, + CreatedAt: t.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + UpdatedAt: t.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + } +} diff --git a/local/model/user.go b/local/model/user.go index 82846d1..284b42a 100644 --- a/local/model/user.go +++ b/local/model/user.go @@ -13,42 +13,98 @@ import ( // User represents a user in the system type User struct { BaseModel - Email string `json:"email" gorm:"unique;not null;type:varchar(255)"` - Username string `json:"username" gorm:"unique;not null;type:varchar(100)"` - Name string `json:"name" gorm:"not null;type:varchar(255)"` - PasswordHash string `json:"-" gorm:"not null;type:text"` - Active bool `json:"active" gorm:"default:true"` - EmailVerified bool `json:"emailVerified" gorm:"default:false"` - EmailVerificationToken string `json:"-" gorm:"type:varchar(255)"` - PasswordResetToken string `json:"-" gorm:"type:varchar(255)"` - PasswordResetExpires *time.Time `json:"-"` - LastLogin *time.Time `json:"lastLogin"` - LoginAttempts int `json:"-" gorm:"default:0"` - LockedUntil *time.Time `json:"-"` - TwoFactorEnabled bool `json:"twoFactorEnabled" gorm:"default:false"` - TwoFactorSecret string `json:"-" gorm:"type:varchar(255)"` - Roles []Role `json:"roles" gorm:"many2many:user_roles;"` - AuditLogs []AuditLog `json:"-" gorm:"foreignKey:UserID"` + Email string `json:"email" gorm:"unique;not null;type:varchar(255)"` + PasswordHash string `json:"-" gorm:"not null;type:varchar(255)"` + FullName string `json:"full_name" gorm:"type:varchar(255)"` + Roles []Role `json:"roles" gorm:"many2many:user_roles;"` } // UserCreateRequest represents the request to create a new user type UserCreateRequest struct { Email string `json:"email" validate:"required,email"` - Username string `json:"username" validate:"required,min=3,max=50"` - Name string `json:"name" validate:"required,min=2,max=100"` + FullName string `json:"full_name" validate:"required,min=2,max=100"` Password string `json:"password" validate:"required,min=8"` RoleIDs []string `json:"roleIds"` } +// ToUser converts UserCreateRequest to User domain model +func (req *UserCreateRequest) ToUser() (*User, error) { + user := &User{ + Email: req.Email, + FullName: req.FullName, + } + + // Handle password hashing + if err := user.SetPassword(req.Password); err != nil { + return nil, err + } + + // Note: Roles will be set by the service layer after validation + return user, nil +} + +// Validate validates the UserCreateRequest +func (req *UserCreateRequest) Validate() error { + if req.Email == "" { + return errors.New("email is required") + } + if !isValidEmail(req.Email) { + return errors.New("invalid email format") + } + if len(req.Password) < 8 { + return errors.New("password must be at least 8 characters") + } + if req.FullName == "" { + return errors.New("full name is required") + } + return nil +} + // UserUpdateRequest represents the request to update a user type UserUpdateRequest struct { Email *string `json:"email,omitempty" validate:"omitempty,email"` - Username *string `json:"username,omitempty" validate:"omitempty,min=3,max=50"` - Name *string `json:"name,omitempty" validate:"omitempty,min=2,max=100"` - Active *bool `json:"active,omitempty"` + FullName *string `json:"full_name,omitempty" validate:"omitempty,min=2,max=100"` RoleIDs []string `json:"roleIds,omitempty"` } +// ApplyToUser applies the UserUpdateRequest to an existing User +func (req *UserUpdateRequest) ApplyToUser(user *User) error { + if req.Email != nil { + user.Email = *req.Email + } + + if req.FullName != nil { + user.FullName = *req.FullName + } + + // Note: Roles will be handled by the service layer + return nil +} + +// Validate validates the UserUpdateRequest +func (req *UserUpdateRequest) Validate() error { + if req.Email != nil && !isValidEmail(*req.Email) { + return errors.New("invalid email format") + } + if req.FullName != nil && len(*req.FullName) == 0 { + return errors.New("full name cannot be empty") + } + if req.FullName != nil && len(*req.FullName) > 255 { + return errors.New("full name must not exceed 255 characters") + } + return nil +} + +// UserResponse represents the response when returning user data +type UserResponse struct { + ID string `json:"id"` + Email string `json:"email"` + FullName string `json:"fullName"` + Roles []string `json:"roles"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + // UserLoginRequest represents a login request type UserLoginRequest struct { Email string `json:"email" validate:"required,email"` @@ -65,16 +121,13 @@ type UserLoginResponse struct { // UserInfo represents public user information type UserInfo struct { - ID string `json:"id"` - Email string `json:"email"` - Username string `json:"username"` - Name string `json:"name"` - Active bool `json:"active"` - EmailVerified bool `json:"emailVerified"` - LastLogin *time.Time `json:"lastLogin"` - Roles []RoleInfo `json:"roles"` - Permissions []string `json:"permissions"` - DateCreated time.Time `json:"dateCreated"` + ID string `json:"id"` + Email string `json:"email"` + FullName string `json:"full_name"` + Roles []RoleInfo `json:"roles"` + Permissions []string `json:"permissions"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // ChangePasswordRequest represents a password change request @@ -98,10 +151,9 @@ type ResetPasswordConfirmRequest struct { func (u *User) BeforeCreate(tx *gorm.DB) error { u.BaseModel.BeforeCreate() - // Normalize email and username + // Normalize email and full name u.Email = strings.ToLower(strings.TrimSpace(u.Email)) - u.Username = strings.ToLower(strings.TrimSpace(u.Username)) - u.Name = strings.TrimSpace(u.Name) + u.FullName = strings.TrimSpace(u.FullName) return u.Validate() } @@ -114,11 +166,8 @@ func (u *User) BeforeUpdate(tx *gorm.DB) error { if u.Email != "" { u.Email = strings.ToLower(strings.TrimSpace(u.Email)) } - if u.Username != "" { - u.Username = strings.ToLower(strings.TrimSpace(u.Username)) - } - if u.Name != "" { - u.Name = strings.TrimSpace(u.Name) + if u.FullName != "" { + u.FullName = strings.TrimSpace(u.FullName) } return u.Validate() @@ -134,24 +183,8 @@ func (u *User) Validate() error { return errors.New("invalid email format") } - if u.Username == "" { - return errors.New("username is required") - } - - if len(u.Username) < 3 || len(u.Username) > 50 { - return errors.New("username must be between 3 and 50 characters") - } - - if !isValidUsername(u.Username) { - return errors.New("username can only contain letters, numbers, underscores, and hyphens") - } - - if u.Name == "" { - return errors.New("name is required") - } - - if len(u.Name) < 2 || len(u.Name) > 100 { - return errors.New("name must be between 2 and 100 characters") + if u.FullName != "" && len(u.FullName) > 255 { + return errors.New("full name must not exceed 255 characters") } return nil @@ -182,55 +215,16 @@ func (u *User) VerifyPassword(plainPassword string) bool { return u.CheckPassword(plainPassword) } -// IsLocked checks if the user account is locked -func (u *User) IsLocked() bool { - if u.LockedUntil == nil { - return false - } - return time.Now().Before(*u.LockedUntil) -} - -// Lock locks the user account for the specified duration -func (u *User) Lock(duration time.Duration) { - lockUntil := time.Now().Add(duration) - u.LockedUntil = &lockUntil -} - -// Unlock unlocks the user account -func (u *User) Unlock() { - u.LockedUntil = nil - u.LoginAttempts = 0 -} - -// IncrementLoginAttempts increments the login attempt counter -func (u *User) IncrementLoginAttempts() { - u.LoginAttempts++ -} - -// ResetLoginAttempts resets the login attempt counter -func (u *User) ResetLoginAttempts() { - u.LoginAttempts = 0 -} - -// UpdateLastLogin updates the last login timestamp -func (u *User) UpdateLastLogin() { - now := time.Now() - u.LastLogin = &now -} - // ToUserInfo converts User to UserInfo (public information) func (u *User) ToUserInfo() UserInfo { userInfo := UserInfo{ - ID: u.ID, - Email: u.Email, - Username: u.Username, - Name: u.Name, - Active: u.Active, - EmailVerified: u.EmailVerified, - LastLogin: u.LastLogin, - DateCreated: u.DateCreated, - Roles: make([]RoleInfo, len(u.Roles)), - Permissions: []string{}, + ID: u.ID, + Email: u.Email, + FullName: u.FullName, + CreatedAt: u.CreatedAt, + UpdatedAt: u.UpdatedAt, + Roles: make([]RoleInfo, len(u.Roles)), + Permissions: []string{}, } // Convert roles and collect permissions @@ -250,6 +244,23 @@ func (u *User) ToUserInfo() UserInfo { return userInfo } +// ToResponse converts User to UserResponse (for API responses) +func (u *User) ToResponse() *UserResponse { + roleNames := make([]string, len(u.Roles)) + for i, role := range u.Roles { + roleNames[i] = role.Name + } + + return &UserResponse{ + ID: u.ID, + Email: u.Email, + FullName: u.FullName, + Roles: roleNames, + CreatedAt: u.CreatedAt, + UpdatedAt: u.UpdatedAt, + } +} + // HasRole checks if the user has a specific role func (u *User) HasRole(roleName string) bool { for _, role := range u.Roles { @@ -278,12 +289,6 @@ func isValidEmail(email string) bool { return emailRegex.MatchString(email) } -// isValidUsername validates username format -func isValidUsername(username string) bool { - usernameRegex := regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) - return usernameRegex.MatchString(username) -} - // validatePassword validates password strength func validatePassword(password string) error { if len(password) < 8 { @@ -294,25 +299,5 @@ func validatePassword(password string) error { return errors.New("password must not exceed 128 characters") } - // Check for at least one lowercase letter - if matched, _ := regexp.MatchString(`[a-z]`, password); !matched { - return errors.New("password must contain at least one lowercase letter") - } - - // Check for at least one uppercase letter - if matched, _ := regexp.MatchString(`[A-Z]`, password); !matched { - return errors.New("password must contain at least one uppercase letter") - } - - // Check for at least one digit - if matched, _ := regexp.MatchString(`\d`, password); !matched { - return errors.New("password must contain at least one digit") - } - - // Check for at least one special character - if matched, _ := regexp.MatchString(`[!@#$%^&*(),.?":{}|<>]`, password); !matched { - return errors.New("password must contain at least one special character") - } - return nil } diff --git a/local/repository/membership.go b/local/repository/membership.go index c9326bd..858d0fe 100644 --- a/local/repository/membership.go +++ b/local/repository/membership.go @@ -22,12 +22,12 @@ func NewMembershipRepository(db *gorm.DB) *MembershipRepository { } } -// FindUserByUsername finds a user by their username. +// FindUserByEmail finds a user by their email. // It preloads the user's role and the role's permissions. -func (r *MembershipRepository) FindUserByUsername(ctx context.Context, username string) (*model.User, error) { +func (r *MembershipRepository) FindUserByEmail(ctx context.Context, email string) (*model.User, error) { var user model.User db := r.db.WithContext(ctx) - err := db.Preload("Roles.Permissions").Where("username = ?", username).First(&user).Error + err := db.Preload("Roles.Permissions").Where("email = ?", email).First(&user).Error if err != nil { return nil, err } diff --git a/local/service/membership.go b/local/service/membership.go index 1ab5b79..749476f 100644 --- a/local/service/membership.go +++ b/local/service/membership.go @@ -8,6 +8,7 @@ import ( "omega-server/local/utl/jwt" "omega-server/local/utl/logging" "os" + "strings" "github.com/google/uuid" ) @@ -38,8 +39,8 @@ func (s *MembershipService) SetCacheInvalidator(invalidator CacheInvalidator) { } // Login authenticates a user and returns a JWT. -func (s *MembershipService) Login(ctx context.Context, username, password string) (string, error) { - user, err := s.repo.FindUserByUsername(ctx, username) +func (s *MembershipService) Login(ctx context.Context, email, password string) (string, error) { + user, err := s.repo.FindUserByEmail(ctx, email) if err != nil { return "", errors.New("invalid credentials") } @@ -55,38 +56,42 @@ func (s *MembershipService) Login(ctx context.Context, username, password string roleNames[i] = role.Name } - return jwt.GenerateToken(user.ID, user.Email, user.Username, roleNames) + return jwt.GenerateToken(user.ID, user.Email, user.FullName, roleNames) } // CreateUser creates a new user. -func (s *MembershipService) CreateUser(ctx context.Context, username, password, roleName string) (*model.User, error) { - - role, err := s.repo.FindRoleByName(ctx, roleName) - if err != nil { - logging.Error("Failed to find role by name: %v", err) - return nil, errors.New("role not found") - } - - user := &model.User{ - Username: username, - Email: username + "@example.com", // You may want to accept email as parameter - Name: username, - } - - // Set password using the model's SetPassword method - if err := user.SetPassword(password); err != nil { +func (s *MembershipService) CreateUser(ctx context.Context, user *model.User, roleIDs []string) (*model.User, error) { + // Validate domain model + if err := user.Validate(); err != nil { return nil, err } - // Assign roles - user.Roles = []model.Role{*role} + // Handle roles + if len(roleIDs) > 0 { + roles := make([]model.Role, 0, len(roleIDs)) + for _, roleID := range roleIDs { + role, err := s.repo.FindRoleByName(ctx, roleID) + if err != nil { + logging.Error("Failed to find role by name: %v", err) + return nil, errors.New("role not found: " + roleID) + } + roles = append(roles, *role) + } + user.Roles = roles + } + // Create user if err := s.repo.CreateUser(ctx, user); err != nil { logging.Error("Failed to create user: %v", err) return nil, err } - logging.InfoOperation("USER_CREATE", "Created user: "+user.Username+" (ID: "+user.ID+", Role: "+roleName+")") + // Log with role names + roleNames := make([]string, len(user.Roles)) + for i, role := range user.Roles { + roleNames[i] = role.Name + } + logging.InfoOperation("USER_CREATE", "Created user: "+user.Email+" (ID: "+user.ID+", Roles: "+strings.Join(roleNames, ", ")+")") return user, nil } @@ -105,13 +110,6 @@ func (s *MembershipService) GetUserWithPermissions(ctx context.Context, userID s return s.repo.FindUserByIDWithPermissions(ctx, userID) } -// UpdateUserRequest defines the request body for updating a user. -type UpdateUserRequest struct { - Username *string `json:"username"` - Password *string `json:"password"` - RoleID *string `json:"roleId"` -} - // DeleteUser deletes a user with validation to prevent Super Admin deletion. func (s *MembershipService) DeleteUser(ctx context.Context, userID uuid.UUID) error { // Get user with role information @@ -142,34 +140,42 @@ func (s *MembershipService) DeleteUser(ctx context.Context, userID uuid.UUID) er } // UpdateUser updates a user's details. -func (s *MembershipService) UpdateUser(ctx context.Context, userID uuid.UUID, req UpdateUserRequest) (*model.User, error) { +func (s *MembershipService) UpdateUser(ctx context.Context, userID uuid.UUID, req *model.UserUpdateRequest) (*model.User, error) { + // Validate request + if err := req.Validate(); err != nil { + return nil, err + } + user, err := s.repo.FindUserByID(ctx, userID) if err != nil { return nil, errors.New("user not found") } - if req.Username != nil { - user.Username = *req.Username + // Apply update request to user + if err := req.ApplyToUser(user); err != nil { + return nil, err } - if req.Password != nil && *req.Password != "" { - // Use the model's SetPassword method to hash the password - if err := user.SetPassword(*req.Password); err != nil { - return nil, err + // Handle roles if provided + if len(req.RoleIDs) > 0 { + roles := make([]model.Role, 0, len(req.RoleIDs)) + for _, roleID := range req.RoleIDs { + roleUUID, err := uuid.Parse(roleID) + if err != nil { + return nil, errors.New("invalid role ID format: " + roleID) + } + role, err := s.repo.FindRoleByID(ctx, roleUUID) + if err != nil { + return nil, errors.New("role not found: " + roleID) + } + roles = append(roles, *role) } + user.Roles = roles } - if req.RoleID != nil { - // Check if role exists - roleUUID, err := uuid.Parse(*req.RoleID) - if err != nil { - return nil, errors.New("invalid role ID format") - } - role, err := s.repo.FindRoleByID(ctx, roleUUID) - if err != nil { - return nil, errors.New("role not found") - } - user.Roles = []model.Role{*role} + // Validate updated user + if err := user.Validate(); err != nil { + return nil, err } if err := s.repo.UpdateUser(ctx, user); err != nil { @@ -177,11 +183,11 @@ func (s *MembershipService) UpdateUser(ctx context.Context, userID uuid.UUID, re } // Invalidate cache if role was changed - if req.RoleID != nil && s.cacheInvalidator != nil { + if len(req.RoleIDs) > 0 && s.cacheInvalidator != nil { s.cacheInvalidator.InvalidateUserPermissions(userID.String()) } - logging.InfoOperation("USER_UPDATE", "Updated user: "+user.Username+" (ID: "+user.ID+")") + logging.InfoOperation("USER_UPDATE", "Updated user: "+user.Email+" (ID: "+user.ID+")") return user, nil } @@ -289,10 +295,17 @@ func (s *MembershipService) SetupInitialData(ctx context.Context) error { } // Create a default admin user if one doesn't exist - _, err = s.repo.FindUserByUsername(ctx, "admin") + _, err = s.repo.FindUserByEmail(ctx, "admin@example.com") if err != nil { logging.Debug("Creating default admin user") - _, err = s.CreateUser(ctx, "admin", os.Getenv("PASSWORD"), "Super Admin") // Default password, should be changed + adminUser := &model.User{ + Email: "admin@example.com", + FullName: "System Administrator", + } + if err := adminUser.SetPassword(os.Getenv("PASSWORD")); err != nil { + return err + } + _, err = s.CreateUser(ctx, adminUser, []string{"Super Admin"}) if err != nil { return err } diff --git a/local/service/membership_interface.go b/local/service/membership_interface.go deleted file mode 100644 index fe90299..0000000 --- a/local/service/membership_interface.go +++ /dev/null @@ -1,28 +0,0 @@ -package service - -import ( - "context" - "omega-server/local/model" - - "github.com/google/uuid" -) - -// MembershipServiceInterface defines the interface for membership-related operations -type MembershipServiceInterface interface { - // Authentication and Authorization - Login(ctx context.Context, username, password string) (string, error) - HasPermission(ctx context.Context, userID string, permissionName string) (bool, error) - GetUserWithPermissions(ctx context.Context, userID string) (*model.User, error) - SetCacheInvalidator(invalidator CacheInvalidator) - - // User Management - CreateUser(ctx context.Context, username, password, roleName string) (*model.User, error) - ListUsers(ctx context.Context) ([]*model.User, error) - GetUser(ctx context.Context, userID uuid.UUID) (*model.User, error) - DeleteUser(ctx context.Context, userID uuid.UUID) error - UpdateUser(ctx context.Context, userID uuid.UUID, req UpdateUserRequest) (*model.User, error) - - // Role Management - GetAllRoles(ctx context.Context) ([]*model.Role, error) - SetupInitialData(ctx context.Context) error -} diff --git a/local/utl/common/types.go b/local/utl/common/types.go index 1e01d4e..986ab8f 100644 --- a/local/utl/common/types.go +++ b/local/utl/common/types.go @@ -107,7 +107,7 @@ func DefaultPagination() PaginationRequest { return PaginationRequest{ Page: 1, Limit: 10, - Sort: "dateCreated", + Sort: "created_at", Order: "desc", } } @@ -121,7 +121,7 @@ func (p *PaginationRequest) Validate() { p.Limit = 10 } if p.Sort == "" { - p.Sort = "dateCreated" + p.Sort = "created_at" } if p.Order != "asc" && p.Order != "desc" { p.Order = "desc" diff --git a/local/utl/db/db.go b/local/utl/db/db.go index 91bf7ef..206f27c 100644 --- a/local/utl/db/db.go +++ b/local/utl/db/db.go @@ -1,23 +1,53 @@ package db import ( + "fmt" "omega-server/local/model" "omega-server/local/utl/logging" "os" "time" "go.uber.org/dig" - "gorm.io/driver/sqlite" + "gorm.io/driver/postgres" "gorm.io/gorm" "gorm.io/gorm/logger" ) func Start(di *dig.Container) { + // PostgreSQL connection configuration + host := os.Getenv("DB_HOST") + if host == "" { + host = "localhost" + } + + port := os.Getenv("DB_PORT") + if port == "" { + port = "5432" + } + + user := os.Getenv("DB_USER") + if user == "" { + user = "postgres" + } + + password := os.Getenv("DB_PASSWORD") + if password == "" { + password = "password" + } + dbName := os.Getenv("DB_NAME") if dbName == "" { - dbName = "app.db" + dbName = "omega_db" } + sslMode := os.Getenv("DB_SSL_MODE") + if sslMode == "" { + sslMode = "disable" + } + + dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=%s TimeZone=UTC", + host, user, password, dbName, port, sslMode) + // Configure GORM logger gormLogger := logger.Default if os.Getenv("LOG_LEVEL") == "DEBUG" { @@ -26,10 +56,14 @@ func Start(di *dig.Container) { gormLogger = logger.Default.LogMode(logger.Silent) } - db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{ + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ Logger: gormLogger, }) if err != nil { + logging.Error("Failed to connect to PostgreSQL database") + logging.Error("Connection string: host=%s user=%s dbname=%s port=%s sslmode=%s", host, user, dbName, port, sslMode) + logging.Error("Error: %v", err) + logging.Error("Make sure PostgreSQL is running and the database exists") logging.Panic("failed to connect database: " + err.Error()) } @@ -62,6 +96,12 @@ func Migrate(db *gorm.DB) { &model.User{}, &model.Role{}, &model.Permission{}, + &model.Type{}, + &model.Project{}, + &model.Task{}, + &model.Integration{}, + &model.ProjectMember{}, + &model.TaskAssignee{}, &model.SystemConfig{}, &model.AuditLog{}, &model.SecurityEvent{}, @@ -87,6 +127,9 @@ func Seed(db *gorm.DB) error { if err := seedDefaultAdmin(db); err != nil { return err } + if err := seedDefaultTypes(db); err != nil { + return err + } if err := seedSystemConfigs(db); err != nil { return err } @@ -193,6 +236,31 @@ func seedPermissions(db *gorm.DB) error { return nil } +func seedDefaultTypes(db *gorm.DB) error { + defaultTypes := []model.Type{ + {Name: "Web Development", Description: "Standard web development project"}, + {Name: "Mobile App", Description: "Mobile application development"}, + {Name: "API Development", Description: "API and backend service development"}, + {Name: "Data Science", Description: "Data analysis and machine learning projects"}, + {Name: "DevOps", Description: "Infrastructure and deployment projects"}, + {Name: "Research", Description: "Research and documentation projects"}, + } + + for _, projectType := range defaultTypes { + var existingType model.Type + err := db.Where("name = ? AND user_id IS NULL", projectType.Name).First(&existingType).Error + if err == gorm.ErrRecordNotFound { + projectType.Init() + if err := db.Create(&projectType).Error; err != nil { + return err + } + logging.Info("Created default project type: %s", projectType.Name) + } + } + + return nil +} + func seedDefaultAdmin(db *gorm.DB) error { // Check if admin user already exists var existingAdmin model.User @@ -214,10 +282,9 @@ func seedDefaultAdmin(db *gorm.DB) error { } admin := model.User{ - Email: "admin@example.com", - Username: "admin", - Name: "System Administrator", - Active: true, + Email: "admin@example.com", + FullName: "System Administrator", + PasswordHash: "", } admin.Init()