diff --git a/cmd/main.go b/cmd/main.go index fa89a95..82c3eed 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -6,6 +6,7 @@ import ( "github.com/Caknoooo/go-gin-clean-starter/middlewares" "github.com/Caknoooo/go-gin-clean-starter/modules/auth" + "github.com/Caknoooo/go-gin-clean-starter/modules/product" "github.com/Caknoooo/go-gin-clean-starter/modules/tenant" "github.com/Caknoooo/go-gin-clean-starter/modules/user" "github.com/Caknoooo/go-gin-clean-starter/providers" @@ -60,12 +61,18 @@ func main() { } server := gin.Default() + + // Atur trusted proxies, misal hanya localhost dan IP proxy tertentu + server.SetTrustedProxies([]string{"127.0.0.1"}) + + // Enable CORS middleware server.Use(middlewares.CORSMiddleware()) // Register module routes user.RegisterRoutes(server, injector) auth.RegisterRoutes(server, injector) tenant.RegisterRoutes(server, injector) + product.RegisterRoutes(server, injector) run(server) } diff --git a/modules/product/controller/product_controller.go b/modules/product/controller/product_controller.go new file mode 100644 index 0000000..48d4375 --- /dev/null +++ b/modules/product/controller/product_controller.go @@ -0,0 +1,128 @@ +package controller + +import ( + "net/http" + + "github.com/Caknoooo/go-gin-clean-starter/modules/product/dto" + "github.com/Caknoooo/go-gin-clean-starter/modules/product/query" + "github.com/Caknoooo/go-gin-clean-starter/modules/product/service" + "github.com/Caknoooo/go-gin-clean-starter/pkg/constants" + "github.com/Caknoooo/go-gin-clean-starter/pkg/utils" + "github.com/Caknoooo/go-pagination" + "github.com/gin-gonic/gin" + "github.com/samber/do" + "gorm.io/gorm" +) + +type ( + ProductController interface { + Create(ctx *gin.Context) + GetAll(ctx *gin.Context) + GetById(ctx *gin.Context) + Update(ctx *gin.Context) + Delete(ctx *gin.Context) + } + + productController struct { + productService service.ProductService + db *gorm.DB + } +) + +func NewProductController(injector *do.Injector, ps service.ProductService) ProductController { + db := do.MustInvokeNamed[*gorm.DB](injector, constants.DB) + return &productController{ + productService: ps, + db: db, + } +} + +func (c *productController) Create(ctx *gin.Context) { + var product dto.ProductCreateRequest + if err := ctx.ShouldBind(&product); err != nil { + res := utils.BuildResponseFailed(dto.MESSAGE_FAILED_GET_DATA_FROM_BODY, err.Error(), nil) + ctx.AbortWithStatusJSON(http.StatusBadRequest, res) + return + } + + tenantID := ctx.MustGet("tenant_id").(string) + product.TenantID = tenantID // set tenantID dari middleware ke request + + result, err := c.productService.Create(ctx.Request.Context(), product) + if err != nil { + res := utils.BuildResponseFailed(dto.MESSAGE_FAILED_REGISTER_PRODUCT, err.Error(), nil) + ctx.JSON(http.StatusBadRequest, res) + return + } + + res := utils.BuildResponseSuccess(dto.MESSAGE_SUCCESS_REGISTER_PRODUCT, result) + ctx.JSON(http.StatusOK, res) +} + +func (c *productController) GetById(ctx *gin.Context) { + productId := ctx.Param("id") + + result, err := c.productService.GetProductById(ctx.Request.Context(), productId) + if err != nil { + res := utils.BuildResponseFailed(dto.MESSAGE_FAILED_GET_PRODUCT, err.Error(), nil) + ctx.JSON(http.StatusBadRequest, res) + return + } + + res := utils.BuildResponseSuccess(dto.MESSAGE_SUCCESS_GET_PRODUCT, result) + ctx.JSON(http.StatusOK, res) +} + +func (c *productController) GetAll(ctx *gin.Context) { + tenantId := ctx.MustGet("tenant_id").(string) + var filter = &query.ProductFilter{ + TenantID: tenantId, + Name: ctx.Query("name"), + } + filter.BindPagination(ctx) + + ctx.ShouldBindQuery(filter) + + products, total, err := pagination.PaginatedQueryWithIncludable[query.Product](c.db, filter) + if err != nil { + res := utils.BuildResponseFailed(dto.MESSAGE_FAILED_GET_PRODUCT, err.Error(), nil) + ctx.JSON(http.StatusBadRequest, res) + return + } + paginationResponse := pagination.CalculatePagination(filter.Pagination, total) + response := pagination.NewPaginatedResponse(http.StatusOK, dto.MESSAGE_SUCCESS_GET_LIST_PRODUCT, products, paginationResponse) + ctx.JSON(http.StatusOK, response) +} + +func (c *productController) Update(ctx *gin.Context) { + productId := ctx.Param("id") + var product dto.ProductUpdateRequest + if err := ctx.ShouldBind(&product); err != nil { + res := utils.BuildResponseFailed(dto.MESSAGE_FAILED_GET_DATA_FROM_BODY, err.Error(), nil) + ctx.AbortWithStatusJSON(http.StatusBadRequest, res) + return + } + + result, err := c.productService.Update(ctx.Request.Context(), product, productId) + if err != nil { + res := utils.BuildResponseFailed(dto.MESSAGE_FAILED_UPDATE_PRODUCT, err.Error(), nil) + ctx.JSON(http.StatusBadRequest, res) + return + } + + res := utils.BuildResponseSuccess(dto.MESSAGE_SUCCESS_UPDATE_PRODUCT, result) + ctx.JSON(http.StatusOK, res) +} + +func (c *productController) Delete(ctx *gin.Context) { + productId := ctx.Param("id") + + if err := c.productService.Delete(ctx.Request.Context(), productId); err != nil { + res := utils.BuildResponseFailed(dto.MESSAGE_FAILED_DELETE_PRODUCT, err.Error(), nil) + ctx.JSON(http.StatusBadRequest, res) + return + } + + res := utils.BuildResponseSuccess(dto.MESSAGE_SUCCESS_DELETE_PRODUCT, nil) + ctx.JSON(http.StatusOK, res) +} diff --git a/modules/product/dto/product_dto.go b/modules/product/dto/product_dto.go new file mode 100644 index 0000000..c5da006 --- /dev/null +++ b/modules/product/dto/product_dto.go @@ -0,0 +1,89 @@ +package dto + +import ( + "errors" + + "github.com/Caknoooo/go-gin-clean-starter/database/entities" + "github.com/Caknoooo/go-gin-clean-starter/pkg/dto" +) + +const ( + // Failed + MESSAGE_FAILED_GET_DATA_FROM_BODY = "failed get data from body" + MESSAGE_FAILED_REGISTER_PRODUCT = "failed create product" + MESSAGE_FAILED_GET_LIST_PRODUCT = "failed get list product" + MESSAGE_FAILED_TOKEN_NOT_VALID = "token not valid" + MESSAGE_FAILED_TOKEN_NOT_FOUND = "token not found" + MESSAGE_FAILED_GET_PRODUCT = "failed get product" + MESSAGE_FAILED_LOGIN = "failed login" + MESSAGE_FAILED_UPDATE_PRODUCT = "failed update product" + MESSAGE_FAILED_DELETE_PRODUCT = "failed delete product" + MESSAGE_FAILED_PROSES_REQUEST = "failed proses request" + MESSAGE_FAILED_DENIED_ACCESS = "denied access" + MESSAGE_FAILED_VERIFY_EMAIL = "failed verify email" + + // Success + MESSAGE_SUCCESS_REGISTER_PRODUCT = "success create product" + MESSAGE_SUCCESS_GET_LIST_PRODUCT = "success get list product" + MESSAGE_SUCCESS_GET_PRODUCT = "success get product" + MESSAGE_SUCCESS_LOGIN = "success login" + MESSAGE_SUCCESS_UPDATE_PRODUCT = "success update product" + MESSAGE_SUCCESS_DELETE_PRODUCT = "success delete product" + MESSAGE_SEND_VERIFICATION_EMAIL_SUCCESS = "success send verification email" + MESSAGE_SUCCESS_VERIFY_EMAIL = "success verify email" +) + +var ( + ErrCreateProduct = errors.New("failed to create product") + ErrGetProductById = errors.New("failed to get product by id") + ErrGetProductByName = errors.New("failed to get product by name") + ErrProductAlreadyExists = errors.New("product already exist") + ErrUpdateProduct = errors.New("failed to update product") + ErrProductNotFound = errors.New("product not found") + ErrEmailNotFound = errors.New("email not found") + ErrDeleteProduct = errors.New("failed to delete product") + ErrTokenInvalid = errors.New("token invalid") + ErrTokenExpired = errors.New("token expired") + ErrAccountAlreadyVerified = errors.New("account already verified") + ErrNameAlreadyExists = errors.New("name already exists") +) + +type ( + ProductCreateRequest struct { + Name string `json:"name" form:"name" binding:"required,min=2,max=100"` + Price float64 `json:"price" form:"price" binding:"required,gt=0"` + // TenantID akan diisi dari context (middleware) + TenantID string `json:"tenant_id" form:"tenant_id" binding:"-"` + } + + ProductResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Price float64 `json:"price"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + } + + ProductPaginationResponse struct { + Data []ProductResponse `json:"data"` + dto.PaginationResponse + } + + GetAllProductRepositoryResponse struct { + Products []entities.Product `json:"products"` + dto.PaginationResponse + } + + ProductUpdateRequest struct { + Name string `json:"name" form:"name" binding:"omitempty,min=2,max=100"` + Price float64 `json:"price" form:"price" binding:"omitempty,gt=0"` + } + + ProductUpdateResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Price float64 `json:"price"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + } +) diff --git a/modules/product/query/product_query.go b/modules/product/query/product_query.go new file mode 100644 index 0000000..c852851 --- /dev/null +++ b/modules/product/query/product_query.go @@ -0,0 +1,64 @@ +package query + +import ( + "github.com/Caknoooo/go-pagination" + "gorm.io/gorm" +) + +type Product struct { + ID string `json:"id"` + Name string `json:"name"` + Price float64 `json:"price"` +} + +type ProductFilter struct { + pagination.BaseFilter + Name string `form:"name"` // tambahkan ini + TenantID string `form:"tenant_id"` // tambahkan ini +} + +func (f *ProductFilter) ApplyFilters(query *gorm.DB) *gorm.DB { + // Apply your filters here + if f.Name != "" { + query = query.Where("name ILIKE ?", "%"+f.Name+"%") + } + if f.TenantID != "" { + query = query.Where("tenant_id = ?", f.TenantID) + } + return query +} + +func (f *ProductFilter) GetTableName() string { + return "products" +} + +func (f *ProductFilter) GetSearchFields() []string { + return []string{"name"} +} + +func (f *ProductFilter) GetDefaultSort() string { + return "id asc" +} + +func (f *ProductFilter) GetIncludes() []string { + return f.Includes +} + +func (f *ProductFilter) GetPagination() pagination.PaginationRequest { + return f.Pagination +} + +func (f *ProductFilter) Validate() { + var validIncludes []string + allowedIncludes := f.GetAllowedIncludes() + for _, include := range f.Includes { + if allowedIncludes[include] { + validIncludes = append(validIncludes, include) + } + } + f.Includes = validIncludes +} + +func (f *ProductFilter) GetAllowedIncludes() map[string]bool { + return map[string]bool{} +} diff --git a/modules/product/repository/product_repository.go b/modules/product/repository/product_repository.go new file mode 100644 index 0000000..cba2b64 --- /dev/null +++ b/modules/product/repository/product_repository.go @@ -0,0 +1,108 @@ +package repository + +import ( + "context" + "errors" + + "github.com/Caknoooo/go-gin-clean-starter/database/entities" + "gorm.io/gorm" +) + +type ( + ProductRepository interface { + Create(ctx context.Context, tx *gorm.DB, product entities.Product) (entities.Product, error) + GetById(ctx context.Context, tx *gorm.DB, productId string) (entities.Product, error) + GetByName(ctx context.Context, tx *gorm.DB, name string) (entities.Product, error) + CheckName(ctx context.Context, tx *gorm.DB, name string) (entities.Product, bool, error) + Update(ctx context.Context, tx *gorm.DB, product entities.Product) (entities.Product, error) + Delete(ctx context.Context, tx *gorm.DB, productId string) error + } + + productRepository struct { + db *gorm.DB + } +) + +func NewProductRepository(db *gorm.DB) ProductRepository { + return &productRepository{ + db: db, + } +} + +func (r *productRepository) Create(ctx context.Context, tx *gorm.DB, product entities.Product) (entities.Product, error) { + if tx == nil { + tx = r.db + } + + if err := tx.WithContext(ctx).Create(&product).Error; err != nil { + return entities.Product{}, err + } + + return product, nil +} + +func (r *productRepository) GetById(ctx context.Context, tx *gorm.DB, productId string) (entities.Product, error) { + if tx == nil { + tx = r.db + } + + var product entities.Product + if err := tx.WithContext(ctx).Where("id = ?", productId).Take(&product).Error; err != nil { + return entities.Product{}, err + } + + return product, nil +} + +func (r *productRepository) GetByName(ctx context.Context, tx *gorm.DB, name string) (entities.Product, error) { + if tx == nil { + tx = r.db + } + + var product entities.Product + if err := tx.WithContext(ctx).Where("name = ?", name).Take(&product).Error; err != nil { + return entities.Product{}, err + } + + return product, nil +} + +func (r *productRepository) Update(ctx context.Context, tx *gorm.DB, product entities.Product) (entities.Product, error) { + if tx == nil { + tx = r.db + } + + if err := tx.WithContext(ctx).Updates(&product).Error; err != nil { + return entities.Product{}, err + } + + return product, nil +} + +func (r *productRepository) Delete(ctx context.Context, tx *gorm.DB, productId string) error { + if tx == nil { + tx = r.db + } + + if err := tx.WithContext(ctx).Delete(&entities.Product{}, "id = ?", productId).Error; err != nil { + return err + } + + return nil +} + +func (r *productRepository) CheckName(ctx context.Context, tx *gorm.DB, name string) (entities.Product, bool, error) { + if tx == nil { + tx = r.db + } + + var product entities.Product + if err := tx.WithContext(ctx).Where("name = ?", name).Take(&product).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return entities.Product{}, false, nil + } + return entities.Product{}, false, err + } + + return product, true, nil +} diff --git a/modules/product/routes.go b/modules/product/routes.go new file mode 100644 index 0000000..eebd8bd --- /dev/null +++ b/modules/product/routes.go @@ -0,0 +1,24 @@ +package product + +import ( + "github.com/Caknoooo/go-gin-clean-starter/middlewares" + "github.com/Caknoooo/go-gin-clean-starter/modules/auth/service" + "github.com/Caknoooo/go-gin-clean-starter/modules/product/controller" + "github.com/Caknoooo/go-gin-clean-starter/pkg/constants" + "github.com/gin-gonic/gin" + "github.com/samber/do" +) + +func RegisterRoutes(server *gin.Engine, injector *do.Injector) { + productController := do.MustInvoke[controller.ProductController](injector) + jwtService := do.MustInvokeNamed[service.JWTService](injector, constants.JWTService) + + productRoutes := server.Group("/api/product") + { + productRoutes.POST("", middlewares.Authenticate(jwtService), productController.Create) + productRoutes.GET("", middlewares.Authenticate(jwtService), productController.GetAll) + productRoutes.GET("/:id", middlewares.Authenticate(jwtService), productController.GetById) + productRoutes.PUT("/:id", middlewares.Authenticate(jwtService), productController.Update) + productRoutes.DELETE("/:id", middlewares.Authenticate(jwtService), productController.Delete) + } +} diff --git a/modules/product/service/product_service.go b/modules/product/service/product_service.go new file mode 100644 index 0000000..afc5d00 --- /dev/null +++ b/modules/product/service/product_service.go @@ -0,0 +1,111 @@ +package service + +import ( + "context" + "time" + + "github.com/Caknoooo/go-gin-clean-starter/database/entities" + "github.com/Caknoooo/go-gin-clean-starter/modules/product/dto" + "github.com/Caknoooo/go-gin-clean-starter/modules/product/repository" + "github.com/google/uuid" + "gorm.io/gorm" +) + +type ProductService interface { + Create(ctx context.Context, req dto.ProductCreateRequest) (dto.ProductResponse, error) + GetProductById(ctx context.Context, productId string) (dto.ProductResponse, error) + Update(ctx context.Context, req dto.ProductUpdateRequest, productId string) (dto.ProductUpdateResponse, error) + Delete(ctx context.Context, productId string) error +} + +type productService struct { + productRepository repository.ProductRepository + db *gorm.DB +} + +func NewProductService( + productRepo repository.ProductRepository, + db *gorm.DB, +) ProductService { + return &productService{ + productRepository: productRepo, + db: db, + } +} + +func (s *productService) Create(ctx context.Context, req dto.ProductCreateRequest) (dto.ProductResponse, error) { + _, exists, err := s.productRepository.CheckName(ctx, s.db, req.Name) + if err != nil && err != gorm.ErrRecordNotFound { + return dto.ProductResponse{}, err + } + if exists { + return dto.ProductResponse{}, dto.ErrNameAlreadyExists + } + + product := entities.Product{ + ID: uuid.New(), + Name: req.Name, + Price: req.Price, + TenantID: uuid.MustParse(req.TenantID), + } + + createdProduct, err := s.productRepository.Create(ctx, s.db, product) + if err != nil { + return dto.ProductResponse{}, err + } + + return dto.ProductResponse{ + ID: createdProduct.ID.String(), + Name: createdProduct.Name, + Price: createdProduct.Price, + CreatedAt: createdProduct.CreatedAt.Format(time.RFC3339), + UpdatedAt: createdProduct.UpdatedAt.Format(time.RFC3339), + }, nil +} + +func (s *productService) GetProductById(ctx context.Context, productId string) (dto.ProductResponse, error) { + product, err := s.productRepository.GetById(ctx, s.db, productId) + if err != nil { + return dto.ProductResponse{}, err + } + + return dto.ProductResponse{ + ID: product.ID.String(), + Name: product.Name, + Price: product.Price, + CreatedAt: product.CreatedAt.Format(time.RFC3339), + UpdatedAt: product.UpdatedAt.Format(time.RFC3339), + }, nil +} + +func (s *productService) Update(ctx context.Context, req dto.ProductUpdateRequest, productId string) (dto.ProductUpdateResponse, error) { + product, err := s.productRepository.GetById(ctx, s.db, productId) + if err != nil { + return dto.ProductUpdateResponse{}, dto.ErrProductNotFound + } + + if req.Name != "" { + product.Name = req.Name + } + + if req.Price >= 0 { + product.Price = req.Price + } + + updatedProduct, err := s.productRepository.Update(ctx, s.db, product) + if err != nil { + return dto.ProductUpdateResponse{}, err + } + + return dto.ProductUpdateResponse{ + ID: updatedProduct.ID.String(), + Name: updatedProduct.Name, + Price: updatedProduct.Price, + CreatedAt: updatedProduct.CreatedAt.Format(time.RFC3339), + UpdatedAt: updatedProduct.UpdatedAt.Format(time.RFC3339), + }, nil +} + +func (s *productService) Delete(ctx context.Context, productId string) error { + return s.productRepository.Delete(ctx, s.db, productId) +} diff --git a/providers/core.go b/providers/core.go index d6c7d1c..4a0c80f 100644 --- a/providers/core.go +++ b/providers/core.go @@ -4,6 +4,9 @@ import ( "github.com/Caknoooo/go-gin-clean-starter/config" authRepo "github.com/Caknoooo/go-gin-clean-starter/modules/auth/repository" "github.com/Caknoooo/go-gin-clean-starter/modules/auth/service" + productController "github.com/Caknoooo/go-gin-clean-starter/modules/product/controller" + productRepo "github.com/Caknoooo/go-gin-clean-starter/modules/product/repository" + productService "github.com/Caknoooo/go-gin-clean-starter/modules/product/service" tenantController "github.com/Caknoooo/go-gin-clean-starter/modules/tenant/controller" tenantRepo "github.com/Caknoooo/go-gin-clean-starter/modules/tenant/repository" tenantService "github.com/Caknoooo/go-gin-clean-starter/modules/tenant/service" @@ -36,10 +39,12 @@ func RegisterDependencies(injector *do.Injector) { userRepository := repository.NewUserRepository(db) tenantRepository := tenantRepo.NewTenantRepository(db) refreshTokenRepository := authRepo.NewRefreshTokenRepository(db) + productRepository := productRepo.NewProductRepository(db) // Service userService := userService.NewUserService(userRepository, refreshTokenRepository, jwtService, db) tenantService := tenantService.NewTenantService(tenantRepository, db) + productService := productService.NewProductService(productRepository, db) // Controller do.Provide( @@ -52,4 +57,9 @@ func RegisterDependencies(injector *do.Injector) { return tenantController.NewTenantController(i, tenantService), nil }, ) + do.Provide( + injector, func(i *do.Injector) (productController.ProductController, error) { + return productController.NewProductController(i, productService), nil + }, + ) }