From b9c900578d834c9f22ab2eb7fdde92f38cf7cf22 Mon Sep 17 00:00:00 2001 From: Jean Jacques Avril Date: Wed, 12 Mar 2025 13:52:34 +0000 Subject: [PATCH] refactor: remove repeating code etc --- .clinerules | 1 + .../{dtos => api/dto}/activity_dto.go | 0 .../internal/{dtos => api/dto}/auth_dto.go | 0 .../internal/{dtos => api/dto}/company_dto.go | 0 .../{dtos => api/dto}/customer_dto.go | 0 .../internal/{dtos => api/dto}/project_dto.go | 0 .../{dtos => api/dto}/timeentry_dto.go | 0 .../internal/{dtos => api/dto}/user_dto.go | 2 +- .../internal/api/handlers/activity_handler.go | 96 +++--- .../internal/api/handlers/company_handler.go | 90 +++--- .../internal/api/handlers/customer_handler.go | 134 ++++----- .../internal/api/handlers/project_handler.go | 154 +++------- .../api/handlers/timeentry_handler.go | 277 ++++-------------- backend/internal/api/handlers/user_handler.go | 222 ++++++-------- .../api/middleware/api_key_middleware.go | 6 +- backend/internal/api/middleware/jwt_auth.go | 27 +- .../api/{utils => responses}/response.go | 2 +- backend/internal/api/utils/handler_utils.go | 174 ++++++++++- backend/tygo.yaml | 2 +- refactor_plan.md | 25 ++ 20 files changed, 529 insertions(+), 683 deletions(-) rename backend/internal/{dtos => api/dto}/activity_dto.go (100%) rename backend/internal/{dtos => api/dto}/auth_dto.go (100%) rename backend/internal/{dtos => api/dto}/company_dto.go (100%) rename backend/internal/{dtos => api/dto}/customer_dto.go (100%) rename backend/internal/{dtos => api/dto}/project_dto.go (100%) rename backend/internal/{dtos => api/dto}/timeentry_dto.go (100%) rename backend/internal/{dtos => api/dto}/user_dto.go (94%) rename backend/internal/api/{utils => responses}/response.go (99%) create mode 100644 refactor_plan.md diff --git a/.clinerules b/.clinerules index 19d376d..2c413e3 100644 --- a/.clinerules +++ b/.clinerules @@ -37,6 +37,7 @@ SOLVE TASKS AS FAST AS POSSIBLE. EACH REQUEST COSTS THE USER MONEY. - Unexpected behavior is encountered - Specific conditions require warnings - New patterns emerge that need documentation +- DO NOT FIX UNUSED IMPORTS - this is the job of the linter 10.Implement a REST API update handling in Go using Gin that ensures the following behavior: - The update request is received as JSON. - If a field is present in the JSON and set to null, the corresponding value in the database should be removed. diff --git a/backend/internal/dtos/activity_dto.go b/backend/internal/api/dto/activity_dto.go similarity index 100% rename from backend/internal/dtos/activity_dto.go rename to backend/internal/api/dto/activity_dto.go diff --git a/backend/internal/dtos/auth_dto.go b/backend/internal/api/dto/auth_dto.go similarity index 100% rename from backend/internal/dtos/auth_dto.go rename to backend/internal/api/dto/auth_dto.go diff --git a/backend/internal/dtos/company_dto.go b/backend/internal/api/dto/company_dto.go similarity index 100% rename from backend/internal/dtos/company_dto.go rename to backend/internal/api/dto/company_dto.go diff --git a/backend/internal/dtos/customer_dto.go b/backend/internal/api/dto/customer_dto.go similarity index 100% rename from backend/internal/dtos/customer_dto.go rename to backend/internal/api/dto/customer_dto.go diff --git a/backend/internal/dtos/project_dto.go b/backend/internal/api/dto/project_dto.go similarity index 100% rename from backend/internal/dtos/project_dto.go rename to backend/internal/api/dto/project_dto.go diff --git a/backend/internal/dtos/timeentry_dto.go b/backend/internal/api/dto/timeentry_dto.go similarity index 100% rename from backend/internal/dtos/timeentry_dto.go rename to backend/internal/api/dto/timeentry_dto.go diff --git a/backend/internal/dtos/user_dto.go b/backend/internal/api/dto/user_dto.go similarity index 94% rename from backend/internal/dtos/user_dto.go rename to backend/internal/api/dto/user_dto.go index 89d49fd..689de5e 100644 --- a/backend/internal/dtos/user_dto.go +++ b/backend/internal/api/dto/user_dto.go @@ -13,7 +13,7 @@ type UserDto struct { LastEditorID string `json:"lastEditorID" example:"01HGW2BBG0000000000000000"` Email string `json:"email" example:"test@example.com"` Role string `json:"role" example:"admin"` - CompanyID *string `json:"companyId" example:"01HGW2BBG0000000000000000"` + CompanyID *string `json:"companyId,omitempty" example:"01HGW2BBG0000000000000000"` HourlyRate float64 `json:"hourlyRate" example:"50.00"` } diff --git a/backend/internal/api/handlers/activity_handler.go b/backend/internal/api/handlers/activity_handler.go index 610dac4..9de0fbc 100644 --- a/backend/internal/api/handlers/activity_handler.go +++ b/backend/internal/api/handlers/activity_handler.go @@ -1,12 +1,13 @@ package handlers import ( - "net/http" + "context" "github.com/gin-gonic/gin" "github.com/oklog/ulid/v2" + "github.com/timetracker/backend/internal/api/dto" + "github.com/timetracker/backend/internal/api/responses" "github.com/timetracker/backend/internal/api/utils" - dto "github.com/timetracker/backend/internal/dtos" "github.com/timetracker/backend/internal/models" "github.com/timetracker/backend/internal/types" ) @@ -69,27 +70,7 @@ func (h *ActivityHandler) GetActivityByID(c *gin.Context) { // @Failure 500 {object} utils.Response{error=utils.ErrorInfo} // @Router /activities [post] func (h *ActivityHandler) CreateActivity(c *gin.Context) { - // Parse request body - var activityCreateDTO dto.ActivityCreateDto - if err := utils.BindJSON(c, &activityCreateDTO); err != nil { - utils.BadRequestResponse(c, err.Error()) - return - } - - // Convert DTO to model - activityCreate := convertCreateActivityDTOToModel(activityCreateDTO) - - // Create activity in the database - activity, err := models.CreateActivity(c.Request.Context(), activityCreate) - if err != nil { - utils.InternalErrorResponse(c, "Error creating activity: "+err.Error()) - return - } - - // Convert to DTO - activityDTO := convertActivityToDTO(activity) - - utils.SuccessResponse(c, http.StatusCreated, activityDTO) + utils.HandleCreate(c, createActivityWrapper, convertActivityToDTO, "activity") } // UpdateActivity handles PUT /activities/:id @@ -109,42 +90,7 @@ func (h *ActivityHandler) CreateActivity(c *gin.Context) { // @Failure 500 {object} utils.Response{error=utils.ErrorInfo} // @Router /activities/{id} [put] func (h *ActivityHandler) UpdateActivity(c *gin.Context) { - // Parse ID from URL - id, err := utils.ParseID(c, "id") - if err != nil { - utils.BadRequestResponse(c, "Invalid activity ID format") - return - } - - // Parse request body - var activityUpdateDTO dto.ActivityUpdateDto - if err := utils.BindJSON(c, &activityUpdateDTO); err != nil { - utils.BadRequestResponse(c, err.Error()) - return - } - - // Set ID from URL - activityUpdateDTO.ID = id.String() - - // Convert DTO to model - activityUpdate := convertUpdateActivityDTOToModel(activityUpdateDTO) - - // Update activity in the database - activity, err := models.UpdateActivity(c.Request.Context(), activityUpdate) - if err != nil { - utils.InternalErrorResponse(c, "Error updating activity: "+err.Error()) - return - } - - if activity == nil { - utils.NotFoundResponse(c, "Activity not found") - return - } - - // Convert to DTO - activityDTO := convertActivityToDTO(activity) - - utils.SuccessResponse(c, http.StatusOK, activityDTO) + utils.HandleUpdate(c, models.UpdateActivity, convertActivityToDTO, prepareActivityUpdate, "activity") } // DeleteActivity handles DELETE /activities/:id @@ -200,3 +146,35 @@ func convertUpdateActivityDTOToModel(dto dto.ActivityUpdateDto) models.ActivityU return update } + +// prepareActivityUpdate prepares the activity update object by parsing the ID, binding the JSON, and converting the DTO to a model +func prepareActivityUpdate(c *gin.Context) (models.ActivityUpdate, error) { + // Parse ID from URL + id, err := utils.ParseID(c, "id") + if err != nil { + responses.BadRequestResponse(c, "Invalid activity ID format") + return models.ActivityUpdate{}, err + } + + // Parse request body + var activityUpdateDTO dto.ActivityUpdateDto + if err := utils.BindJSON(c, &activityUpdateDTO); err != nil { + responses.BadRequestResponse(c, err.Error()) + return models.ActivityUpdate{}, err + } + + // Set ID from URL + activityUpdateDTO.ID = id.String() + + // Convert DTO to model + return convertUpdateActivityDTOToModel(activityUpdateDTO), nil +} + +// createActivityWrapper is a wrapper function for models.CreateActivity that takes a DTO as input +func createActivityWrapper(ctx context.Context, createDTO dto.ActivityCreateDto) (*models.Activity, error) { + // Convert DTO to model + activityCreate := convertCreateActivityDTOToModel(createDTO) + + // Call the original function + return models.CreateActivity(ctx, activityCreate) +} diff --git a/backend/internal/api/handlers/company_handler.go b/backend/internal/api/handlers/company_handler.go index b045f58..d3c7700 100644 --- a/backend/internal/api/handlers/company_handler.go +++ b/backend/internal/api/handlers/company_handler.go @@ -1,11 +1,12 @@ package handlers import ( - "net/http" + "context" "github.com/gin-gonic/gin" + "github.com/timetracker/backend/internal/api/dto" + "github.com/timetracker/backend/internal/api/responses" "github.com/timetracker/backend/internal/api/utils" - dto "github.com/timetracker/backend/internal/dtos" "github.com/timetracker/backend/internal/models" "github.com/timetracker/backend/internal/types" ) @@ -68,27 +69,7 @@ func (h *CompanyHandler) GetCompanyByID(c *gin.Context) { // @Failure 500 {object} utils.Response{error=utils.ErrorInfo} // @Router /companies [post] func (h *CompanyHandler) CreateCompany(c *gin.Context) { - // Parse request body - var companyCreateDTO dto.CompanyCreateDto - if err := utils.BindJSON(c, &companyCreateDTO); err != nil { - utils.BadRequestResponse(c, err.Error()) - return - } - - // Convert DTO to model - companyCreate := convertCreateCompanyDTOToModel(companyCreateDTO) - - // Create company in the database - company, err := models.CreateCompany(c.Request.Context(), companyCreate) - if err != nil { - utils.InternalErrorResponse(c, "Error creating company: "+err.Error()) - return - } - - // Convert to DTO - companyDTO := convertCompanyToDTO(company) - - utils.SuccessResponse(c, http.StatusCreated, companyDTO) + utils.HandleCreate(c, createCompanyWrapper, convertCompanyToDTO, "company") } // UpdateCompany handles PUT /companies/:id @@ -108,39 +89,7 @@ func (h *CompanyHandler) CreateCompany(c *gin.Context) { // @Failure 500 {object} utils.Response{error=utils.ErrorInfo} // @Router /companies/{id} [put] func (h *CompanyHandler) UpdateCompany(c *gin.Context) { - // Parse ID from URL - id, err := utils.ParseID(c, "id") - if err != nil { - utils.BadRequestResponse(c, "Invalid company ID format") - return - } - - // Parse request body - var companyUpdateDTO dto.CompanyUpdateDto - if err := utils.BindJSON(c, &companyUpdateDTO); err != nil { - utils.BadRequestResponse(c, err.Error()) - return - } - - // Convert DTO to model - companyUpdate := convertUpdateCompanyDTOToModel(companyUpdateDTO, id) - - // Update company in the database - company, err := models.UpdateCompany(c.Request.Context(), companyUpdate) - if err != nil { - utils.InternalErrorResponse(c, "Error updating company: "+err.Error()) - return - } - - if company == nil { - utils.NotFoundResponse(c, "Company not found") - return - } - - // Convert to DTO - companyDTO := convertCompanyToDTO(company) - - utils.SuccessResponse(c, http.StatusOK, companyDTO) + utils.HandleUpdate(c, models.UpdateCompany, convertCompanyToDTO, prepareCompanyUpdate, "company") } // DeleteCompany handles DELETE /companies/:id @@ -189,3 +138,32 @@ func convertUpdateCompanyDTOToModel(dto dto.CompanyUpdateDto, id types.ULID) mod return update } + +// prepareCompanyUpdate prepares the company update object by parsing the ID, binding the JSON, and converting the DTO to a model +func prepareCompanyUpdate(c *gin.Context) (models.CompanyUpdate, error) { + // Parse ID from URL + id, err := utils.ParseID(c, "id") + if err != nil { + responses.BadRequestResponse(c, "Invalid company ID format") + return models.CompanyUpdate{}, err + } + + // Parse request body + var companyUpdateDTO dto.CompanyUpdateDto + if err := utils.BindJSON(c, &companyUpdateDTO); err != nil { + responses.BadRequestResponse(c, err.Error()) + return models.CompanyUpdate{}, err + } + + // Convert DTO to model + return convertUpdateCompanyDTOToModel(companyUpdateDTO, id), nil +} + +// createCompanyWrapper is a wrapper function for models.CreateCompany that takes a DTO as input +func createCompanyWrapper(ctx context.Context, createDTO dto.CompanyCreateDto) (*models.Company, error) { + // Convert DTO to model + companyCreate := convertCreateCompanyDTOToModel(createDTO) + + // Call the original function + return models.CreateCompany(ctx, companyCreate) +} diff --git a/backend/internal/api/handlers/customer_handler.go b/backend/internal/api/handlers/customer_handler.go index f879616..48fc392 100644 --- a/backend/internal/api/handlers/customer_handler.go +++ b/backend/internal/api/handlers/customer_handler.go @@ -1,13 +1,15 @@ package handlers import ( + "context" "fmt" "net/http" "github.com/gin-gonic/gin" + "github.com/timetracker/backend/internal/api/dto" "github.com/timetracker/backend/internal/api/middleware" + "github.com/timetracker/backend/internal/api/responses" "github.com/timetracker/backend/internal/api/utils" - dto "github.com/timetracker/backend/internal/dtos" "github.com/timetracker/backend/internal/models" "github.com/timetracker/backend/internal/types" ) @@ -74,21 +76,26 @@ func (h *CustomerHandler) GetCustomersByCompanyID(c *gin.Context) { companyIDStr := c.Param("companyId") companyID, err := parseCompanyID(companyIDStr) if err != nil { - utils.BadRequestResponse(c, "Invalid company ID format") + responses.BadRequestResponse(c, "Invalid company ID format") return } - // Get customers from the database - customers, err := models.GetCustomersByCompanyID(c.Request.Context(), companyID) + // Create a wrapper function that takes a ULID but calls the original function with an int + getByCompanyIDFn := func(ctx context.Context, _ types.ULID) ([]models.Customer, error) { + return models.GetCustomersByCompanyID(ctx, companyID) + } + + // Get customers from the database and convert to DTOs + customers, err := getByCompanyIDFn(c.Request.Context(), types.ULID{}) if err != nil { - utils.InternalErrorResponse(c, "Error retrieving customers: "+err.Error()) + responses.InternalErrorResponse(c, "Error retrieving customers: "+err.Error()) return } // Convert to DTOs customerDTOs := utils.ConvertToDTO(customers, convertCustomerToDTO) - utils.SuccessResponse(c, http.StatusOK, customerDTOs) + responses.SuccessResponse(c, http.StatusOK, customerDTOs) } // CreateCustomer handles POST /customers @@ -106,37 +113,19 @@ func (h *CustomerHandler) GetCustomersByCompanyID(c *gin.Context) { // @Failure 500 {object} utils.Response{error=utils.ErrorInfo} // @Router /customers [post] func (h *CustomerHandler) CreateCustomer(c *gin.Context) { + // We need to use a custom wrapper for CreateCustomer because we need to get the user ID from the context userID, err := middleware.GetUserIDFromContext(c) if err != nil { - utils.UnauthorizedResponse(c, "User not authenticated") - return - } - // Parse request body - var customerCreateDTO dto.CustomerCreateDto - if err := utils.BindJSON(c, &customerCreateDTO); err != nil { - utils.BadRequestResponse(c, err.Error()) + responses.UnauthorizedResponse(c, "User not authenticated") return } - // Convert DTO to model - customerCreate, err := convertCreateCustomerDTOToModel(customerCreateDTO) - if err != nil { - utils.BadRequestResponse(c, "Invalid request body: "+err.Error()) - return - } - customerCreate.OwnerUserID = &userID - - // Create customer in the database - customer, err := models.CreateCustomer(c.Request.Context(), customerCreate) - if err != nil { - utils.InternalErrorResponse(c, "Error creating customer: "+err.Error()) - return + // Use a closure to capture the userID + createFn := func(ctx context.Context, createDTO dto.CustomerCreateDto) (*models.Customer, error) { + return createCustomerWrapper(ctx, createDTO, userID) } - // Convert to DTO - customerDTO := convertCustomerToDTO(customer) - - utils.SuccessResponse(c, http.StatusCreated, customerDTO) + utils.HandleCreate(c, createFn, convertCustomerToDTO, "customer") } // UpdateCustomer handles PUT /customers/:id @@ -156,46 +145,7 @@ func (h *CustomerHandler) CreateCustomer(c *gin.Context) { // @Failure 500 {object} utils.Response{error=utils.ErrorInfo} // @Router /customers/{id} [put] func (h *CustomerHandler) UpdateCustomer(c *gin.Context) { - // Parse ID from URL - id, err := utils.ParseID(c, "id") - if err != nil { - utils.BadRequestResponse(c, "Invalid customer ID format") - return - } - - // Parse request body - var customerUpdateDTO dto.CustomerUpdateDto - if err := utils.BindJSON(c, &customerUpdateDTO); err != nil { - utils.BadRequestResponse(c, err.Error()) - return - } - - // Set ID from URL - customerUpdateDTO.ID = id.String() - - // Convert DTO to model - customerUpdate, err := convertUpdateCustomerDTOToModel(customerUpdateDTO) - if err != nil { - utils.BadRequestResponse(c, "Invalid request body: "+err.Error()) - return - } - - // Update customer in the database - customer, err := models.UpdateCustomer(c.Request.Context(), customerUpdate) - if err != nil { - utils.InternalErrorResponse(c, "Error updating customer: "+err.Error()) - return - } - - if customer == nil { - utils.NotFoundResponse(c, "Customer not found") - return - } - - // Convert to DTO - customerDTO := convertCustomerToDTO(customer) - - utils.SuccessResponse(c, http.StatusOK, customerDTO) + utils.HandleUpdate(c, models.UpdateCustomer, convertCustomerToDTO, prepareCustomerUpdate, "customer") } // DeleteCustomer handles DELETE /customers/:id @@ -282,3 +232,47 @@ func parseCompanyID(idStr string) (int, error) { _, err := fmt.Sscanf(idStr, "%d", &id) return id, err } + +// prepareCustomerUpdate prepares the customer update object by parsing the ID, binding the JSON, and converting the DTO to a model +func prepareCustomerUpdate(c *gin.Context) (models.CustomerUpdate, error) { + // Parse ID from URL + id, err := utils.ParseID(c, "id") + if err != nil { + responses.BadRequestResponse(c, "Invalid customer ID format") + return models.CustomerUpdate{}, err + } + + // Parse request body + var customerUpdateDTO dto.CustomerUpdateDto + if err := utils.BindJSON(c, &customerUpdateDTO); err != nil { + responses.BadRequestResponse(c, err.Error()) + return models.CustomerUpdate{}, err + } + + // Set ID from URL + customerUpdateDTO.ID = id.String() + + // Convert DTO to model + customerUpdate, err := convertUpdateCustomerDTOToModel(customerUpdateDTO) + if err != nil { + responses.BadRequestResponse(c, "Invalid request body: "+err.Error()) + return models.CustomerUpdate{}, err + } + + return customerUpdate, nil +} + +// createCustomerWrapper is a wrapper function for models.CreateCustomer that takes a DTO as input +func createCustomerWrapper(ctx context.Context, createDTO dto.CustomerCreateDto, userID types.ULID) (*models.Customer, error) { + // Convert DTO to model + customerCreate, err := convertCreateCustomerDTOToModel(createDTO) + if err != nil { + return nil, err + } + + // Set the owner user ID + customerCreate.OwnerUserID = &userID + + // Call the original function + return models.CreateCustomer(ctx, customerCreate) +} diff --git a/backend/internal/api/handlers/project_handler.go b/backend/internal/api/handlers/project_handler.go index c4f6dca..9eeaf94 100644 --- a/backend/internal/api/handlers/project_handler.go +++ b/backend/internal/api/handlers/project_handler.go @@ -1,12 +1,13 @@ package handlers import ( + "context" "fmt" - "net/http" "github.com/gin-gonic/gin" + "github.com/timetracker/backend/internal/api/dto" + "github.com/timetracker/backend/internal/api/responses" "github.com/timetracker/backend/internal/api/utils" - dto "github.com/timetracker/backend/internal/dtos" "github.com/timetracker/backend/internal/models" "github.com/timetracker/backend/internal/types" ) @@ -48,17 +49,7 @@ func (h *ProjectHandler) GetProjects(c *gin.Context) { // @Failure 500 {object} utils.Response{error=utils.ErrorInfo} // @Router /projects/with-customers [get] func (h *ProjectHandler) GetProjectsWithCustomers(c *gin.Context) { - // Get projects with customers from the database - projects, err := models.GetAllProjectsWithCustomers(c.Request.Context()) - if err != nil { - utils.InternalErrorResponse(c, "Error retrieving projects: "+err.Error()) - return - } - - // Convert to DTOs - projectDTOs := utils.ConvertToDTO(projects, convertProjectToDTO) - - utils.SuccessResponse(c, http.StatusOK, projectDTOs) + utils.HandleGetAll(c, models.GetAllProjectsWithCustomers, convertProjectToDTO, "projects with customers") } // GetProjectByID handles GET /projects/:id @@ -95,24 +86,7 @@ func (h *ProjectHandler) GetProjectByID(c *gin.Context) { // @Failure 500 {object} utils.Response{error=utils.ErrorInfo} // @Router /projects/customer/{customerId} [get] func (h *ProjectHandler) GetProjectsByCustomerID(c *gin.Context) { - // Parse customer ID from URL - customerID, err := utils.ParseID(c, "customerId") - if err != nil { - utils.BadRequestResponse(c, "Invalid customer ID format") - return - } - - // Get projects from the database - projects, err := models.GetProjectsByCustomerID(c.Request.Context(), customerID) - if err != nil { - utils.InternalErrorResponse(c, "Error retrieving projects: "+err.Error()) - return - } - - // Convert to DTOs - projectDTOs := utils.ConvertToDTO(projects, convertProjectToDTO) - - utils.SuccessResponse(c, http.StatusOK, projectDTOs) + utils.HandleGetByFilter(c, models.GetProjectsByCustomerID, convertProjectToDTO, "projects", "customerId") } // CreateProject handles POST /projects @@ -130,31 +104,7 @@ func (h *ProjectHandler) GetProjectsByCustomerID(c *gin.Context) { // @Failure 500 {object} utils.Response{error=utils.ErrorInfo} // @Router /projects [post] func (h *ProjectHandler) CreateProject(c *gin.Context) { - // Parse request body - var projectCreateDTO dto.ProjectCreateDto - if err := utils.BindJSON(c, &projectCreateDTO); err != nil { - utils.BadRequestResponse(c, err.Error()) - return - } - - // Convert DTO to model - projectCreate, err := convertCreateProjectDTOToModel(projectCreateDTO) - if err != nil { - utils.BadRequestResponse(c, err.Error()) - return - } - - // Create project in the database - project, err := models.CreateProject(c.Request.Context(), projectCreate) - if err != nil { - utils.InternalErrorResponse(c, "Error creating project: "+err.Error()) - return - } - - // Convert to DTO - projectDTO := convertProjectToDTO(project) - - utils.SuccessResponse(c, http.StatusCreated, projectDTO) + utils.HandleCreate(c, createProjectWrapper, convertProjectToDTO, "project") } // UpdateProject handles PUT /projects/:id @@ -174,43 +124,7 @@ func (h *ProjectHandler) CreateProject(c *gin.Context) { // @Failure 500 {object} utils.Response{error=utils.ErrorInfo} // @Router /projects/{id} [put] func (h *ProjectHandler) UpdateProject(c *gin.Context) { - // Parse ID from URL - id, err := utils.ParseID(c, "id") - if err != nil { - utils.BadRequestResponse(c, "Invalid project ID format") - return - } - - // Parse request body - var projectUpdateDTO dto.ProjectUpdateDto - if err := utils.BindJSON(c, &projectUpdateDTO); err != nil { - utils.BadRequestResponse(c, err.Error()) - return - } - - // Convert DTO to model - projectUpdate, err := convertUpdateProjectDTOToModel(projectUpdateDTO, id) - if err != nil { - utils.BadRequestResponse(c, err.Error()) - return - } - - // Update project in the database - project, err := models.UpdateProject(c.Request.Context(), projectUpdate) - if err != nil { - utils.InternalErrorResponse(c, "Error updating project: "+err.Error()) - return - } - - if project == nil { - utils.NotFoundResponse(c, "Project not found") - return - } - - // Convert to DTO - projectDTO := convertProjectToDTO(project) - - utils.SuccessResponse(c, http.StatusOK, projectDTO) + utils.HandleUpdate(c, models.UpdateProject, convertProjectToDTO, prepareProjectUpdate, "project") } // DeleteProject handles DELETE /projects/:id @@ -228,21 +142,7 @@ func (h *ProjectHandler) UpdateProject(c *gin.Context) { // @Failure 500 {object} utils.Response{error=utils.ErrorInfo} // @Router /projects/{id} [delete] func (h *ProjectHandler) DeleteProject(c *gin.Context) { - // Parse ID from URL - id, err := utils.ParseID(c, "id") - if err != nil { - utils.BadRequestResponse(c, "Invalid project ID format") - return - } - - // Delete project from the database - err = models.DeleteProject(c.Request.Context(), id) - if err != nil { - utils.InternalErrorResponse(c, "Error deleting project: "+err.Error()) - return - } - - utils.SuccessResponse(c, http.StatusNoContent, nil) + utils.HandleDelete(c, models.DeleteProject, "project") } // Helper functions for DTO conversion @@ -294,3 +194,41 @@ func convertUpdateProjectDTOToModel(dto dto.ProjectUpdateDto, id types.ULID) (mo return update, nil } + +// prepareProjectUpdate prepares the project update object by parsing the ID, binding the JSON, and converting the DTO to a model +func prepareProjectUpdate(c *gin.Context) (models.ProjectUpdate, error) { + // Parse ID from URL + id, err := utils.ParseID(c, "id") + if err != nil { + responses.BadRequestResponse(c, "Invalid project ID format") + return models.ProjectUpdate{}, err + } + + // Parse request body + var projectUpdateDTO dto.ProjectUpdateDto + if err := utils.BindJSON(c, &projectUpdateDTO); err != nil { + responses.BadRequestResponse(c, err.Error()) + return models.ProjectUpdate{}, err + } + + // Convert DTO to model + projectUpdate, err := convertUpdateProjectDTOToModel(projectUpdateDTO, id) + if err != nil { + responses.BadRequestResponse(c, err.Error()) + return models.ProjectUpdate{}, err + } + + return projectUpdate, nil +} + +// createProjectWrapper is a wrapper function for models.CreateProject that takes a DTO as input +func createProjectWrapper(ctx context.Context, createDTO dto.ProjectCreateDto) (*models.Project, error) { + // Convert DTO to model + projectCreate, err := convertCreateProjectDTOToModel(createDTO) + if err != nil { + return nil, err + } + + // Call the original function + return models.CreateProject(ctx, projectCreate) +} diff --git a/backend/internal/api/handlers/timeentry_handler.go b/backend/internal/api/handlers/timeentry_handler.go index 17dc5ae..53b266e 100644 --- a/backend/internal/api/handlers/timeentry_handler.go +++ b/backend/internal/api/handlers/timeentry_handler.go @@ -1,15 +1,13 @@ package handlers import ( + "context" "fmt" - "net/http" - "time" "github.com/gin-gonic/gin" - "github.com/oklog/ulid/v2" - "github.com/timetracker/backend/internal/api/middleware" + "github.com/timetracker/backend/internal/api/dto" + "github.com/timetracker/backend/internal/api/responses" "github.com/timetracker/backend/internal/api/utils" - dto "github.com/timetracker/backend/internal/dtos" "github.com/timetracker/backend/internal/models" "github.com/timetracker/backend/internal/types" ) @@ -35,20 +33,7 @@ func NewTimeEntryHandler() *TimeEntryHandler { // @Failure 500 {object} utils.Response{error=utils.ErrorInfo} // @Router /time-entries [get] func (h *TimeEntryHandler) GetTimeEntries(c *gin.Context) { - // Get time entries from the database - timeEntries, err := models.GetAllTimeEntries(c.Request.Context()) - if err != nil { - utils.InternalErrorResponse(c, "Error retrieving time entries: "+err.Error()) - return - } - - // Convert to DTOs - timeEntryDTOs := make([]dto.TimeEntryDto, len(timeEntries)) - for i, timeEntry := range timeEntries { - timeEntryDTOs[i] = convertTimeEntryToDTO(&timeEntry) - } - - utils.SuccessResponse(c, http.StatusOK, timeEntryDTOs) + utils.HandleGetAll(c, models.GetAllTimeEntries, convertTimeEntryToDTO, "time entries") } // GetTimeEntryByID handles GET /time-entries/:id @@ -67,30 +52,7 @@ func (h *TimeEntryHandler) GetTimeEntries(c *gin.Context) { // @Failure 500 {object} utils.Response{error=utils.ErrorInfo} // @Router /time-entries/{id} [get] func (h *TimeEntryHandler) GetTimeEntryByID(c *gin.Context) { - // Parse ID from URL - idStr := c.Param("id") - id, err := ulid.Parse(idStr) - if err != nil { - utils.BadRequestResponse(c, "Invalid time entry ID format") - return - } - - // Get time entry from the database - timeEntry, err := models.GetTimeEntryByID(c.Request.Context(), types.FromULID(id)) - if err != nil { - utils.InternalErrorResponse(c, "Error retrieving time entry: "+err.Error()) - return - } - - if timeEntry == nil { - utils.NotFoundResponse(c, "Time entry not found") - return - } - - // Convert to DTO - timeEntryDTO := convertTimeEntryToDTO(timeEntry) - - utils.SuccessResponse(c, http.StatusOK, timeEntryDTO) + utils.HandleGetByID(c, models.GetTimeEntryByID, convertTimeEntryToDTO, "time entry") } // GetTimeEntriesByUserID handles GET /time-entries/user/:userId @@ -108,28 +70,7 @@ func (h *TimeEntryHandler) GetTimeEntryByID(c *gin.Context) { // @Failure 500 {object} utils.Response{error=utils.ErrorInfo} // @Router /time-entries/user/{userId} [get] func (h *TimeEntryHandler) GetTimeEntriesByUserID(c *gin.Context) { - // Parse user ID from URL - userIDStr := c.Param("userId") - userID, err := ulid.Parse(userIDStr) - if err != nil { - utils.BadRequestResponse(c, "Invalid user ID format") - return - } - - // Get time entries from the database - timeEntries, err := models.GetTimeEntriesByUserID(c.Request.Context(), types.FromULID(userID)) - if err != nil { - utils.InternalErrorResponse(c, "Error retrieving time entries: "+err.Error()) - return - } - - // Convert to DTOs - timeEntryDTOs := make([]dto.TimeEntryDto, len(timeEntries)) - for i, timeEntry := range timeEntries { - timeEntryDTOs[i] = convertTimeEntryToDTO(&timeEntry) - } - - utils.SuccessResponse(c, http.StatusOK, timeEntryDTOs) + utils.HandleGetByFilter(c, models.GetTimeEntriesByUserID, convertTimeEntryToDTO, "time entries", "userId") } // GetMyTimeEntries handles GET /time-entries/me @@ -145,27 +86,7 @@ func (h *TimeEntryHandler) GetTimeEntriesByUserID(c *gin.Context) { // @Failure 500 {object} utils.Response{error=utils.ErrorInfo} // @Router /time-entries/me [get] func (h *TimeEntryHandler) GetMyTimeEntries(c *gin.Context) { - // Get user ID from context (set by AuthMiddleware) - userID, err := middleware.GetUserIDFromContext(c) - if err != nil { - utils.UnauthorizedResponse(c, "User not authenticated") - return - } - - // Get time entries from the database - timeEntries, err := models.GetTimeEntriesByUserID(c.Request.Context(), userID) - if err != nil { - utils.InternalErrorResponse(c, "Error retrieving time entries: "+err.Error()) - return - } - - // Convert to DTOs - timeEntryDTOs := make([]dto.TimeEntryDto, len(timeEntries)) - for i, timeEntry := range timeEntries { - timeEntryDTOs[i] = convertTimeEntryToDTO(&timeEntry) - } - - utils.SuccessResponse(c, http.StatusOK, timeEntryDTOs) + utils.HandleGetByUserID(c, models.GetTimeEntriesByUserID, convertTimeEntryToDTO, "time entries") } // GetTimeEntriesByProjectID handles GET /time-entries/project/:projectId @@ -183,27 +104,7 @@ func (h *TimeEntryHandler) GetMyTimeEntries(c *gin.Context) { // @Failure 500 {object} utils.Response{error=utils.ErrorInfo} // @Router /time-entries/project/{projectId} [get] func (h *TimeEntryHandler) GetTimeEntriesByProjectID(c *gin.Context) { - // Parse project ID from URL - projectID, err := utils.ParseID(c, "projectId") - if err != nil { - utils.BadRequestResponse(c, "Invalid project ID format") - return - } - - // Get time entries from the database - timeEntries, err := models.GetTimeEntriesByProjectID(c.Request.Context(), projectID) - if err != nil { - utils.InternalErrorResponse(c, "Error retrieving time entries: "+err.Error()) - return - } - - // Convert to DTOs - timeEntryDTOs := make([]dto.TimeEntryDto, len(timeEntries)) - for i, timeEntry := range timeEntries { - timeEntryDTOs[i] = convertTimeEntryToDTO(&timeEntry) - } - - utils.SuccessResponse(c, http.StatusOK, timeEntryDTOs) + utils.HandleGetByFilter(c, models.GetTimeEntriesByProjectID, convertTimeEntryToDTO, "time entries", "projectId") } // GetTimeEntriesByDateRange handles GET /time-entries/range @@ -222,46 +123,7 @@ func (h *TimeEntryHandler) GetTimeEntriesByProjectID(c *gin.Context) { // @Failure 500 {object} utils.Response{error=utils.ErrorInfo} // @Router /time-entries/range [get] func (h *TimeEntryHandler) GetTimeEntriesByDateRange(c *gin.Context) { - // Parse date range from query parameters - startStr := c.Query("start") - endStr := c.Query("end") - - if startStr == "" || endStr == "" { - utils.BadRequestResponse(c, "Start and end dates are required") - return - } - - start, err := time.Parse(time.RFC3339, startStr) - if err != nil { - utils.BadRequestResponse(c, "Invalid start date format. Use ISO 8601 format (e.g., 2023-01-01T00:00:00Z)") - return - } - - end, err := time.Parse(time.RFC3339, endStr) - if err != nil { - utils.BadRequestResponse(c, "Invalid end date format. Use ISO 8601 format (e.g., 2023-01-01T00:00:00Z)") - return - } - - if end.Before(start) { - utils.BadRequestResponse(c, "End date cannot be before start date") - return - } - - // Get time entries from the database - timeEntries, err := models.GetTimeEntriesByDateRange(c.Request.Context(), start, end) - if err != nil { - utils.InternalErrorResponse(c, "Error retrieving time entries: "+err.Error()) - return - } - - // Convert to DTOs - timeEntryDTOs := make([]dto.TimeEntryDto, len(timeEntries)) - for i, timeEntry := range timeEntries { - timeEntryDTOs[i] = convertTimeEntryToDTO(&timeEntry) - } - - utils.SuccessResponse(c, http.StatusOK, timeEntryDTOs) + utils.HandleGetByDateRange(c, models.GetTimeEntriesByDateRange, convertTimeEntryToDTO, "time entries") } // CreateTimeEntry handles POST /time-entries @@ -279,31 +141,7 @@ func (h *TimeEntryHandler) GetTimeEntriesByDateRange(c *gin.Context) { // @Failure 500 {object} utils.Response{error=utils.ErrorInfo} // @Router /time-entries [post] func (h *TimeEntryHandler) CreateTimeEntry(c *gin.Context) { - // Parse request body - var timeEntryCreateDTO dto.TimeEntryCreateDto - if err := c.ShouldBindJSON(&timeEntryCreateDTO); err != nil { - utils.BadRequestResponse(c, "Invalid request body: "+err.Error()) - return - } - - // Convert DTO to model - timeEntryCreate, err := convertCreateTimeEntryDTOToModel(timeEntryCreateDTO) - if err != nil { - utils.BadRequestResponse(c, err.Error()) - return - } - - // Create time entry in the database - timeEntry, err := models.CreateTimeEntry(c.Request.Context(), timeEntryCreate) - if err != nil { - utils.InternalErrorResponse(c, "Error creating time entry: "+err.Error()) - return - } - - // Convert to DTO - timeEntryDTO := convertTimeEntryToDTO(timeEntry) - - utils.SuccessResponse(c, http.StatusCreated, timeEntryDTO) + utils.HandleCreate(c, createTimeEntryWrapper, convertTimeEntryToDTO, "time entry") } // UpdateTimeEntry handles PUT /time-entries/:id @@ -323,44 +161,7 @@ func (h *TimeEntryHandler) CreateTimeEntry(c *gin.Context) { // @Failure 500 {object} utils.Response{error=utils.ErrorInfo} // @Router /time-entries/{id} [put] func (h *TimeEntryHandler) UpdateTimeEntry(c *gin.Context) { - // Parse ID from URL - idStr := c.Param("id") - id, err := types.ULIDFromString(idStr) - if err != nil { - utils.BadRequestResponse(c, "Invalid time entry ID format") - return - } - - // Parse request body - var timeEntryUpdateDTO dto.TimeEntryUpdateDto - if err := c.ShouldBindJSON(&timeEntryUpdateDTO); err != nil { - utils.BadRequestResponse(c, "Invalid request body: "+err.Error()) - return - } - - // Convert DTO to model - timeEntryUpdate, err := convertUpdateTimeEntryDTOToModel(timeEntryUpdateDTO, id) - if err != nil { - utils.BadRequestResponse(c, err.Error()) - return - } - - // Update time entry in the database - timeEntry, err := models.UpdateTimeEntry(c.Request.Context(), timeEntryUpdate) - if err != nil { - utils.InternalErrorResponse(c, "Error updating time entry: "+err.Error()) - return - } - - if timeEntry == nil { - utils.NotFoundResponse(c, "Time entry not found") - return - } - - // Convert to DTO - timeEntryDTO := convertTimeEntryToDTO(timeEntry) - - utils.SuccessResponse(c, http.StatusOK, timeEntryDTO) + utils.HandleUpdate(c, models.UpdateTimeEntry, convertTimeEntryToDTO, prepareTimeEntryUpdate, "time entry") } // DeleteTimeEntry handles DELETE /time-entries/:id @@ -378,22 +179,7 @@ func (h *TimeEntryHandler) UpdateTimeEntry(c *gin.Context) { // @Failure 500 {object} utils.Response{error=utils.ErrorInfo} // @Router /time-entries/{id} [delete] func (h *TimeEntryHandler) DeleteTimeEntry(c *gin.Context) { - // Parse ID from URL - idStr := c.Param("id") - id, err := ulid.Parse(idStr) - if err != nil { - utils.BadRequestResponse(c, "Invalid time entry ID format") - return - } - - // Delete time entry from the database - err = models.DeleteTimeEntry(c.Request.Context(), types.FromULID(id)) - if err != nil { - utils.InternalErrorResponse(c, "Error deleting time entry: "+err.Error()) - return - } - - utils.SuccessResponse(c, http.StatusNoContent, nil) + utils.HandleDelete(c, models.DeleteTimeEntry, "time entry") } // Helper functions for DTO conversion @@ -489,3 +275,42 @@ func convertUpdateTimeEntryDTOToModel(dto dto.TimeEntryUpdateDto, id types.ULID) return update, nil } + +// prepareTimeEntryUpdate prepares the time entry update object by parsing the ID, binding the JSON, and converting the DTO to a model +func prepareTimeEntryUpdate(c *gin.Context) (models.TimeEntryUpdate, error) { + // Parse ID from URL + idStr := c.Param("id") + id, err := types.ULIDFromString(idStr) + if err != nil { + responses.BadRequestResponse(c, "Invalid time entry ID format") + return models.TimeEntryUpdate{}, err + } + + // Parse request body + var timeEntryUpdateDTO dto.TimeEntryUpdateDto + if err := utils.BindJSON(c, &timeEntryUpdateDTO); err != nil { + responses.BadRequestResponse(c, err.Error()) + return models.TimeEntryUpdate{}, err + } + + // Convert DTO to model + timeEntryUpdate, err := convertUpdateTimeEntryDTOToModel(timeEntryUpdateDTO, id) + if err != nil { + responses.BadRequestResponse(c, err.Error()) + return models.TimeEntryUpdate{}, err + } + + return timeEntryUpdate, nil +} + +// createTimeEntryWrapper is a wrapper function for models.CreateTimeEntry that takes a DTO as input +func createTimeEntryWrapper(ctx context.Context, createDTO dto.TimeEntryCreateDto) (*models.TimeEntry, error) { + // Convert DTO to model + timeEntryCreate, err := convertCreateTimeEntryDTOToModel(createDTO) + if err != nil { + return nil, err + } + + // Call the original function + return models.CreateTimeEntry(ctx, timeEntryCreate) +} diff --git a/backend/internal/api/handlers/user_handler.go b/backend/internal/api/handlers/user_handler.go index ce845e2..32d493a 100644 --- a/backend/internal/api/handlers/user_handler.go +++ b/backend/internal/api/handlers/user_handler.go @@ -1,13 +1,14 @@ package handlers import ( + "context" "net/http" "github.com/gin-gonic/gin" - "github.com/oklog/ulid/v2" + "github.com/timetracker/backend/internal/api/dto" "github.com/timetracker/backend/internal/api/middleware" + "github.com/timetracker/backend/internal/api/responses" "github.com/timetracker/backend/internal/api/utils" - dto "github.com/timetracker/backend/internal/dtos" "github.com/timetracker/backend/internal/models" "github.com/timetracker/backend/internal/types" ) @@ -33,20 +34,7 @@ func NewUserHandler() *UserHandler { // @Failure 500 {object} utils.Response{error=utils.ErrorInfo} // @Router /users [get] func (h *UserHandler) GetUsers(c *gin.Context) { - // Get users from the database - users, err := models.GetAllUsers(c.Request.Context()) - if err != nil { - utils.InternalErrorResponse(c, "Error retrieving users: "+err.Error()) - return - } - - // Convert to DTOs - userDTOs := make([]dto.UserDto, len(users)) - for i, user := range users { - userDTOs[i] = convertUserToDTO(&user) - } - - utils.SuccessResponse(c, http.StatusOK, userDTOs) + utils.HandleGetAll(c, models.GetAllUsers, convertUserToDTO, "users") } // GetUserByID handles GET /users/:id @@ -65,30 +53,29 @@ func (h *UserHandler) GetUsers(c *gin.Context) { // @Failure 500 {object} utils.Response{error=utils.ErrorInfo} // @Router /users/{id} [get] func (h *UserHandler) GetUserByID(c *gin.Context) { - // Parse ID from URL - idStr := c.Param("id") - id, err := ulid.Parse(idStr) + // We need a custom wrapper for GetUserByID because the ID parameter is parsed differently + id, err := utils.ParseID(c, "id") if err != nil { - utils.BadRequestResponse(c, "Invalid user ID format") + responses.BadRequestResponse(c, "Invalid user ID format") return } // Get user from the database - user, err := models.GetUserByID(c.Request.Context(), types.FromULID(id)) + user, err := models.GetUserByID(c.Request.Context(), id) if err != nil { - utils.InternalErrorResponse(c, "Error retrieving user: "+err.Error()) + responses.InternalErrorResponse(c, "Error retrieving user: "+err.Error()) return } if user == nil { - utils.NotFoundResponse(c, "User not found") + responses.NotFoundResponse(c, "User not found") return } // Convert to DTO userDTO := convertUserToDTO(user) - utils.SuccessResponse(c, http.StatusOK, userDTO) + responses.SuccessResponse(c, http.StatusOK, userDTO) } // CreateUser handles POST /users @@ -106,27 +93,7 @@ func (h *UserHandler) GetUserByID(c *gin.Context) { // @Failure 500 {object} utils.Response{error=utils.ErrorInfo} // @Router /users [post] func (h *UserHandler) CreateUser(c *gin.Context) { - // Parse request body - var userCreateDTO dto.UserCreateDto - if err := c.ShouldBindJSON(&userCreateDTO); err != nil { - utils.BadRequestResponse(c, "Invalid request body: "+err.Error()) - return - } - - // Convert DTO to model - userCreate := convertCreateDTOToModel(userCreateDTO) - - // Create user in the database - user, err := models.CreateUser(c.Request.Context(), userCreate) - if err != nil { - utils.InternalErrorResponse(c, "Error creating user: "+err.Error()) - return - } - - // Convert to DTO - userDTO := convertUserToDTO(user) - - utils.SuccessResponse(c, http.StatusCreated, userDTO) + utils.HandleCreate(c, createUserWrapper, convertUserToDTO, "user") } // UpdateUser handles PUT /users/:id @@ -146,68 +113,7 @@ func (h *UserHandler) CreateUser(c *gin.Context) { // @Failure 500 {object} utils.Response{error=utils.ErrorInfo} // @Router /users/{id} [put] func (h *UserHandler) UpdateUser(c *gin.Context) { - // Parse ID from URL - idStr := c.Param("id") - id, err := types.ULIDFromString(idStr) - if err != nil { - utils.BadRequestResponse(c, "Invalid user ID format") - return - } - - // Parse request body - var userUpdateDTO dto.UserUpdateDto - if err := c.ShouldBindJSON(&userUpdateDTO); err != nil { - utils.BadRequestResponse(c, "Invalid request body: "+err.Error()) - return - } - - // Convert DTO to Model - update := models.UserUpdate{ - ID: id, - } - - if userUpdateDTO.Email != nil { - update.Email = userUpdateDTO.Email - } - if userUpdateDTO.Password != nil { - update.Password = userUpdateDTO.Password - } - if userUpdateDTO.Role != nil { - update.Role = userUpdateDTO.Role - } - - if userUpdateDTO.CompanyID.Valid { - if userUpdateDTO.CompanyID.Value != nil { - companyID, err := types.ULIDFromString(*userUpdateDTO.CompanyID.Value) - if err != nil { - utils.BadRequestResponse(c, "Invalid company ID format") - return - } - update.CompanyID = types.NewNullable(companyID) - } else { - update.CompanyID = types.Null[types.ULID]() - } - } - - if userUpdateDTO.HourlyRate != nil { - update.HourlyRate = userUpdateDTO.HourlyRate - } - // Update user in the database - user, err := models.UpdateUser(c.Request.Context(), update) - if err != nil { - utils.InternalErrorResponse(c, "Error updating user: "+err.Error()) - return - } - - if user == nil { - utils.NotFoundResponse(c, "User not found") - return - } - - // Convert to DTO - userDTO := convertUserToDTO(user) - - utils.SuccessResponse(c, http.StatusOK, userDTO) + utils.HandleUpdate(c, models.UpdateUser, convertUserToDTO, prepareUserUpdate, "user") } // DeleteUser handles DELETE /users/:id @@ -225,22 +131,7 @@ func (h *UserHandler) UpdateUser(c *gin.Context) { // @Failure 500 {object} utils.Response{error=utils.ErrorInfo} // @Router /users/{id} [delete] func (h *UserHandler) DeleteUser(c *gin.Context) { - // Parse ID from URL - idStr := c.Param("id") - id, err := ulid.Parse(idStr) - if err != nil { - utils.BadRequestResponse(c, "Invalid user ID format") - return - } - - // Delete user from the database - err = models.DeleteUser(c.Request.Context(), types.FromULID(id)) - if err != nil { - utils.InternalErrorResponse(c, "Error deleting user: "+err.Error()) - return - } - - utils.SuccessResponse(c, http.StatusNoContent, nil) + utils.HandleDelete(c, models.DeleteUser, "user") } // Login handles POST /auth/login @@ -260,21 +151,21 @@ func (h *UserHandler) Login(c *gin.Context) { // Parse request body var loginDTO dto.LoginDto if err := c.ShouldBindJSON(&loginDTO); err != nil { - utils.BadRequestResponse(c, "Invalid request body: "+err.Error()) + responses.BadRequestResponse(c, "Invalid request body: "+err.Error()) return } // Authenticate user user, err := models.AuthenticateUser(c.Request.Context(), loginDTO.Email, loginDTO.Password) if err != nil { - utils.UnauthorizedResponse(c, "Invalid login credentials") + responses.UnauthorizedResponse(c, "Invalid login credentials") return } // Generate JWT token token, err := middleware.GenerateToken(user, c) if err != nil { - utils.InternalErrorResponse(c, "Error generating token: "+err.Error()) + responses.InternalErrorResponse(c, "Error generating token: "+err.Error()) return } @@ -284,7 +175,7 @@ func (h *UserHandler) Login(c *gin.Context) { User: convertUserToDTO(user), } - utils.SuccessResponse(c, http.StatusOK, tokenDTO) + responses.SuccessResponse(c, http.StatusOK, tokenDTO) } // Register handles POST /auth/register @@ -303,7 +194,7 @@ func (h *UserHandler) Register(c *gin.Context) { // Parse request body var userCreateDTO dto.UserCreateDto if err := c.ShouldBindJSON(&userCreateDTO); err != nil { - utils.BadRequestResponse(c, "Invalid request body: "+err.Error()) + responses.BadRequestResponse(c, "Invalid request body: "+err.Error()) return } @@ -313,14 +204,14 @@ func (h *UserHandler) Register(c *gin.Context) { // Create user in the database user, err := models.CreateUser(c.Request.Context(), userCreate) if err != nil { - utils.InternalErrorResponse(c, "Error creating user: "+err.Error()) + responses.InternalErrorResponse(c, "Error creating user: "+err.Error()) return } // Generate JWT token token, err := middleware.GenerateToken(user, c) if err != nil { - utils.InternalErrorResponse(c, "Error generating token: "+err.Error()) + responses.InternalErrorResponse(c, "Error generating token: "+err.Error()) return } @@ -330,7 +221,7 @@ func (h *UserHandler) Register(c *gin.Context) { User: convertUserToDTO(user), } - utils.SuccessResponse(c, http.StatusCreated, tokenDTO) + responses.SuccessResponse(c, http.StatusCreated, tokenDTO) } // GetCurrentUser handles GET /auth/me @@ -349,26 +240,26 @@ func (h *UserHandler) GetCurrentUser(c *gin.Context) { // Get user ID from context (set by AuthMiddleware) userID, err := middleware.GetUserIDFromContext(c) if err != nil { - utils.UnauthorizedResponse(c, "User not authenticated") + responses.UnauthorizedResponse(c, "User not authenticated") return } // Get user from the database user, err := models.GetUserByID(c.Request.Context(), userID) if err != nil { - utils.InternalErrorResponse(c, "Error retrieving user: "+err.Error()) + responses.InternalErrorResponse(c, "Error retrieving user: "+err.Error()) return } if user == nil { - utils.NotFoundResponse(c, "User not found") + responses.NotFoundResponse(c, "User not found") return } // Convert to DTO userDTO := convertUserToDTO(user) - utils.SuccessResponse(c, http.StatusOK, userDTO) + responses.SuccessResponse(c, http.StatusOK, userDTO) } // Helper functions for DTO conversion @@ -390,6 +281,58 @@ func convertUserToDTO(user *models.User) dto.UserDto { } } +// prepareUserUpdate prepares the user update object by parsing the ID, binding the JSON, and converting the DTO to a model +func prepareUserUpdate(c *gin.Context) (models.UserUpdate, error) { + // Parse ID from URL + idStr := c.Param("id") + id, err := types.ULIDFromString(idStr) + if err != nil { + responses.BadRequestResponse(c, "Invalid user ID format") + return models.UserUpdate{}, err + } + + // Parse request body + var userUpdateDTO dto.UserUpdateDto + if err := utils.BindJSON(c, &userUpdateDTO); err != nil { + responses.BadRequestResponse(c, err.Error()) + return models.UserUpdate{}, err + } + + // Convert DTO to Model + update := models.UserUpdate{ + ID: id, + } + + if userUpdateDTO.Email != nil { + update.Email = userUpdateDTO.Email + } + if userUpdateDTO.Password != nil { + update.Password = userUpdateDTO.Password + } + if userUpdateDTO.Role != nil { + update.Role = userUpdateDTO.Role + } + + if userUpdateDTO.CompanyID.Valid { + if userUpdateDTO.CompanyID.Value != nil { + companyID, err := types.ULIDFromString(*userUpdateDTO.CompanyID.Value) + if err != nil { + responses.BadRequestResponse(c, "Invalid company ID format") + return models.UserUpdate{}, err + } + update.CompanyID = types.NewNullable(companyID) + } else { + update.CompanyID = types.Null[types.ULID]() + } + } + + if userUpdateDTO.HourlyRate != nil { + update.HourlyRate = userUpdateDTO.HourlyRate + } + + return update, nil +} + func convertCreateDTOToModel(dto dto.UserCreateDto) models.UserCreate { var companyID *types.ULID if dto.CompanyID != nil { @@ -405,3 +348,12 @@ func convertCreateDTOToModel(dto dto.UserCreateDto) models.UserCreate { HourlyRate: dto.HourlyRate, } } + +// createUserWrapper is a wrapper function for models.CreateUser that takes a DTO as input +func createUserWrapper(ctx context.Context, createDTO dto.UserCreateDto) (*models.User, error) { + // Convert DTO to model + userCreate := convertCreateDTOToModel(createDTO) + + // Call the original function + return models.CreateUser(ctx, userCreate) +} diff --git a/backend/internal/api/middleware/api_key_middleware.go b/backend/internal/api/middleware/api_key_middleware.go index 4a5376a..a10f1e0 100644 --- a/backend/internal/api/middleware/api_key_middleware.go +++ b/backend/internal/api/middleware/api_key_middleware.go @@ -2,7 +2,7 @@ package middleware import ( "github.com/gin-gonic/gin" - "github.com/timetracker/backend/internal/api/utils" + "github.com/timetracker/backend/internal/api/responses" "github.com/timetracker/backend/internal/config" ) @@ -18,14 +18,14 @@ func APIKeyMiddleware(cfg *config.Config) gin.HandlerFunc { // Get API key from header apiKey := c.GetHeader("X-API-Key") if apiKey == "" { - utils.UnauthorizedResponse(c, "API key is required") + responses.UnauthorizedResponse(c, "API key is required") c.Abort() return } // Validate API key if apiKey != cfg.APIKey { - utils.UnauthorizedResponse(c, "Invalid API key") + responses.UnauthorizedResponse(c, "Invalid API key") c.Abort() return } diff --git a/backend/internal/api/middleware/jwt_auth.go b/backend/internal/api/middleware/jwt_auth.go index 003b6be..7fc68ba 100644 --- a/backend/internal/api/middleware/jwt_auth.go +++ b/backend/internal/api/middleware/jwt_auth.go @@ -14,7 +14,7 @@ import ( "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" "github.com/oklog/ulid/v2" - "github.com/timetracker/backend/internal/api/utils" + "github.com/timetracker/backend/internal/api/responses" "github.com/timetracker/backend/internal/config" "github.com/timetracker/backend/internal/models" "github.com/timetracker/backend/internal/types" @@ -164,10 +164,10 @@ func loadPublicKey(path string) (*rsa.PublicKey, error) { // Claims represents the JWT claims type Claims struct { - UserID string `json:"userId"` - Email string `json:"email"` - Role string `json:"role"` - CompanyID string `json:"companyId"` + UserID string `json:"userId"` + Email string `json:"email"` + Role string `json:"role"` + CompanyID *string `json:"companyId"` jwt.RegisteredClaims } @@ -177,14 +177,14 @@ func AuthMiddleware() gin.HandlerFunc { // Get the token from cookie tokenString, err := c.Cookie("jwt") if err != nil { - utils.UnauthorizedResponse(c, "Authentication cookie is required") + responses.UnauthorizedResponse(c, "Authentication cookie is required") c.Abort() return } claims, err := validateToken(tokenString) if err != nil { - utils.UnauthorizedResponse(c, "Invalid or expired token") + responses.UnauthorizedResponse(c, "Invalid or expired token") c.Abort() return } @@ -204,7 +204,7 @@ func RoleMiddleware(roles ...string) gin.HandlerFunc { return func(c *gin.Context) { userRole, exists := c.Get("role") if !exists { - utils.UnauthorizedResponse(c, "User role not found in context") + responses.UnauthorizedResponse(c, "User role not found in context") c.Abort() return } @@ -212,7 +212,7 @@ func RoleMiddleware(roles ...string) gin.HandlerFunc { // Check if the user's role is in the allowed roles roleStr, ok := userRole.(string) if !ok { - utils.InternalErrorResponse(c, "Invalid role type in context") + responses.InternalErrorResponse(c, "Invalid role type in context") c.Abort() return } @@ -226,7 +226,7 @@ func RoleMiddleware(roles ...string) gin.HandlerFunc { } if !allowed { - utils.ForbiddenResponse(c, "Insufficient permissions") + responses.ForbiddenResponse(c, "Insufficient permissions") c.Abort() return } @@ -238,11 +238,16 @@ func RoleMiddleware(roles ...string) gin.HandlerFunc { // GenerateToken creates a new JWT token for a user func GenerateToken(user *models.User, c *gin.Context) (string, error) { // Create the claims + var companyId *string + if user.CompanyID != nil { + wrapper := user.CompanyID.String() + companyId = &wrapper + } claims := Claims{ UserID: user.ID.String(), Email: user.Email, Role: user.Role, - CompanyID: user.CompanyID.String(), + CompanyID: companyId, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(config.MustLoadConfig().JWTConfig.TokenDuration)), IssuedAt: jwt.NewNumericDate(time.Now()), diff --git a/backend/internal/api/utils/response.go b/backend/internal/api/responses/response.go similarity index 99% rename from backend/internal/api/utils/response.go rename to backend/internal/api/responses/response.go index dd9a4ce..f1183e4 100644 --- a/backend/internal/api/utils/response.go +++ b/backend/internal/api/responses/response.go @@ -1,4 +1,4 @@ -package utils +package responses import ( "net/http" diff --git a/backend/internal/api/utils/handler_utils.go b/backend/internal/api/utils/handler_utils.go index e5b5914..69df9de 100644 --- a/backend/internal/api/utils/handler_utils.go +++ b/backend/internal/api/utils/handler_utils.go @@ -3,8 +3,11 @@ package utils import ( "context" "fmt" + "net/http" + "time" "github.com/gin-gonic/gin" + "github.com/timetracker/backend/internal/api/responses" "github.com/timetracker/backend/internal/types" ) @@ -43,14 +46,14 @@ func HandleGetAll[M any, D any]( // Get entities from the database entities, err := getAllFn(c.Request.Context()) if err != nil { - InternalErrorResponse(c, fmt.Sprintf("Error retrieving %s: %s", entityName, err.Error())) + responses.InternalErrorResponse(c, fmt.Sprintf("Error retrieving %s: %s", entityName, err.Error())) return } // Convert to DTOs dtos := ConvertToDTO(entities, convertFn) - SuccessResponse(c, 200, dtos) + responses.SuccessResponse(c, 200, dtos) } // HandleGetByID is a generic function to handle GET entity by ID endpoints @@ -63,26 +66,26 @@ func HandleGetByID[M any, D any]( // Parse ID from URL id, err := ParseID(c, "id") if err != nil { - BadRequestResponse(c, fmt.Sprintf("Invalid %s ID format", entityName)) + responses.BadRequestResponse(c, fmt.Sprintf("Invalid %s ID format", entityName)) return } // Get entity from the database entity, err := getByIDFn(c.Request.Context(), id) if err != nil { - InternalErrorResponse(c, fmt.Sprintf("Error retrieving %s: %s", entityName, err.Error())) + responses.InternalErrorResponse(c, fmt.Sprintf("Error retrieving %s: %s", entityName, err.Error())) return } if entity == nil { - NotFoundResponse(c, fmt.Sprintf("%s not found", entityName)) + responses.NotFoundResponse(c, fmt.Sprintf("%s not found", entityName)) return } // Convert to DTO dto := convertFn(entity) - SuccessResponse(c, 200, dto) + responses.SuccessResponse(c, 200, dto) } // HandleCreate is a generic function to handle POST entity endpoints @@ -95,21 +98,21 @@ func HandleCreate[C any, M any, D any]( // Parse request body var createDTO C if err := BindJSON(c, &createDTO); err != nil { - BadRequestResponse(c, err.Error()) + responses.BadRequestResponse(c, err.Error()) return } // Create entity in the database entity, err := createFn(c.Request.Context(), createDTO) if err != nil { - InternalErrorResponse(c, fmt.Sprintf("Error creating %s: %s", entityName, err.Error())) + responses.InternalErrorResponse(c, fmt.Sprintf("Error creating %s: %s", entityName, err.Error())) return } // Convert to DTO dto := convertFn(entity) - SuccessResponse(c, 201, dto) + responses.SuccessResponse(c, 201, dto) } // HandleDelete is a generic function to handle DELETE entity endpoints @@ -121,16 +124,163 @@ func HandleDelete( // Parse ID from URL id, err := ParseID(c, "id") if err != nil { - BadRequestResponse(c, fmt.Sprintf("Invalid %s ID format", entityName)) + responses.BadRequestResponse(c, fmt.Sprintf("Invalid %s ID format", entityName)) return } // Delete entity from the database err = deleteFn(c.Request.Context(), id) if err != nil { - InternalErrorResponse(c, fmt.Sprintf("Error deleting %s: %s", entityName, err.Error())) + responses.InternalErrorResponse(c, fmt.Sprintf("Error deleting %s: %s", entityName, err.Error())) return } - SuccessResponse(c, 204, nil) + responses.SuccessResponse(c, 204, nil) +} + +// HandleUpdate is a generic function to handle PUT entity endpoints +// It takes a prepareUpdateFn that handles parsing the ID, binding the JSON, and converting the DTO to a model update object +func HandleUpdate[U any, M any, D any]( + c *gin.Context, + updateFn func(ctx context.Context, update U) (*M, error), + convertFn func(*M) D, + prepareUpdateFn func(*gin.Context) (U, error), + entityName string, +) { + // Prepare the update object (parse ID, bind JSON, convert DTO to model) + update, err := prepareUpdateFn(c) + if err != nil { + // The prepareUpdateFn should handle setting the appropriate error response + return + } + + // Update entity in the database + entity, err := updateFn(c.Request.Context(), update) + if err != nil { + responses.InternalErrorResponse(c, fmt.Sprintf("Error updating %s: %s", entityName, err.Error())) + return + } + + if entity == nil { + responses.NotFoundResponse(c, fmt.Sprintf("%s not found", entityName)) + return + } + + // Convert to DTO + dto := convertFn(entity) + + responses.SuccessResponse(c, http.StatusOK, dto) +} + +// HandleGetByFilter is a generic function to handle GET entities by a filter parameter +func HandleGetByFilter[M any, D any]( + c *gin.Context, + getByFilterFn func(ctx context.Context, filterID types.ULID) ([]M, error), + convertFn func(*M) D, + entityName string, + paramName string, +) { + // Parse filter ID from URL + filterID, err := ParseID(c, paramName) + if err != nil { + responses.BadRequestResponse(c, fmt.Sprintf("Invalid %s ID format", paramName)) + return + } + + // Get entities from the database + entities, err := getByFilterFn(c.Request.Context(), filterID) + if err != nil { + responses.InternalErrorResponse(c, fmt.Sprintf("Error retrieving %s: %s", entityName, err.Error())) + return + } + + // Convert to DTOs + dtos := ConvertToDTO(entities, convertFn) + + responses.SuccessResponse(c, http.StatusOK, dtos) +} + +// HandleGetByUserID is a specialized function to handle GET entities by user ID +func HandleGetByUserID[M any, D any]( + c *gin.Context, + getByUserIDFn func(ctx context.Context, userID types.ULID) ([]M, error), + convertFn func(*M) D, + entityName string, +) { + // Get user ID from context (set by AuthMiddleware) + userID, exists := c.Get("userID") + if !exists { + responses.UnauthorizedResponse(c, "User not authenticated") + return + } + + userIDStr, ok := userID.(string) + if !ok { + responses.InternalErrorResponse(c, "Invalid user ID type in context") + return + } + + parsedUserID, err := types.ULIDFromString(userIDStr) + if err != nil { + responses.InternalErrorResponse(c, fmt.Sprintf("Error parsing user ID: %s", err.Error())) + return + } + + // Get entities from the database + entities, err := getByUserIDFn(c.Request.Context(), parsedUserID) + if err != nil { + responses.InternalErrorResponse(c, fmt.Sprintf("Error retrieving %s: %s", entityName, err.Error())) + return + } + + // Convert to DTOs + dtos := ConvertToDTO(entities, convertFn) + + responses.SuccessResponse(c, http.StatusOK, dtos) +} + +// HandleGetByDateRange is a specialized function to handle GET entities by date range +func HandleGetByDateRange[M any, D any]( + c *gin.Context, + getByDateRangeFn func(ctx context.Context, start, end time.Time) ([]M, error), + convertFn func(*M) D, + entityName string, +) { + // Parse date range from query parameters + startStr := c.Query("start") + endStr := c.Query("end") + + if startStr == "" || endStr == "" { + responses.BadRequestResponse(c, "Start and end dates are required") + return + } + + start, err := time.Parse(time.RFC3339, startStr) + if err != nil { + responses.BadRequestResponse(c, "Invalid start date format. Use ISO 8601 format (e.g., 2023-01-01T00:00:00Z)") + return + } + + end, err := time.Parse(time.RFC3339, endStr) + if err != nil { + responses.BadRequestResponse(c, "Invalid end date format. Use ISO 8601 format (e.g., 2023-01-01T00:00:00Z)") + return + } + + if end.Before(start) { + responses.BadRequestResponse(c, "End date cannot be before start date") + return + } + + // Get entities from the database + entities, err := getByDateRangeFn(c.Request.Context(), start, end) + if err != nil { + responses.InternalErrorResponse(c, fmt.Sprintf("Error retrieving %s: %s", entityName, err.Error())) + return + } + + // Convert to DTOs + dtos := ConvertToDTO(entities, convertFn) + + responses.SuccessResponse(c, http.StatusOK, dtos) } diff --git a/backend/tygo.yaml b/backend/tygo.yaml index 8d5f1c5..b06aa15 100644 --- a/backend/tygo.yaml +++ b/backend/tygo.yaml @@ -1,5 +1,5 @@ packages: - - path: github.com/timetracker/backend/internal/dtos + - path: "github.com/timetracker/backend/internal/api/dto" type_mappings: "time.Time": "string" "types.ULID": "string" diff --git a/refactor_plan.md b/refactor_plan.md new file mode 100644 index 0000000..500dc99 --- /dev/null +++ b/refactor_plan.md @@ -0,0 +1,25 @@ +# Refactoring Plan for backend/internal/api/handlers + +## Goal + +Refactor the code in `backend/internal/api/handlers` to reduce repetition and create helper functions for boilerplate operations, utilizing functions from `backend/internal/api/utils/handler_utils.go` and creating new ones if necessary. + +## Analysis + +The following common patterns were identified in the handler files: + +1. **Error Handling:** Each handler function repeats the same error handling pattern. +2. **DTO Binding:** Parsing the request body and handling potential errors. +3. **ID Parsing:** Parsing the ID from the URL and handling potential errors. +4. **DTO Conversion:** Converting between DTOs and models. +5. **Success Responses:** Calling `responses.SuccessResponse` with the appropriate HTTP status code and data. +6. **Not Found Responses:** Checking if a record exists and calling `responses.NotFoundResponse` if it doesn't. + +The `Update` handler is the most complex and has the most potential for refactoring. + +## Plan + +1. **Implement a generic `HandleUpdate` function in `handler_utils.go`:** This function will encapsulate the common logic for updating entities, including parsing the ID, binding the JSON, converting the DTO to a model, calling the update function, and handling errors and not found cases. The function will also handle nullable fields correctly. +2. **Modify the existing handlers to use the new `HandleUpdate` function:** This will involve removing the duplicated code from each handler and calling the generic function instead. +3. **Create new helper functions in `handler_utils.go` if needed:** If there are any specific operations that are not covered by the existing utility functions, I will create new ones to handle them. +