package handlers import ( "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/utils" dto "github.com/timetracker/backend/internal/dtos" "github.com/timetracker/backend/internal/models" ) // TimeEntryHandler handles time entry-related API endpoints type TimeEntryHandler struct{} // NewTimeEntryHandler creates a new TimeEntryHandler func NewTimeEntryHandler() *TimeEntryHandler { return &TimeEntryHandler{} } // GetTimeEntries handles GET /time-entries // // @Summary Get all time entries // @Description Get a list of all time entries // @Tags time-entries // @Accept json // @Produce json // @Security BearerAuth // @Success 200 {object} utils.Response{data=[]dto.TimeEntryDto} // @Failure 401 {object} utils.Response{error=utils.ErrorInfo} // @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) } // GetTimeEntryByID handles GET /time-entries/:id // // @Summary Get time entry by ID // @Description Get a time entry by its ID // @Tags time-entries // @Accept json // @Produce json // @Security BearerAuth // @Param id path string true "Time Entry ID" // @Success 200 {object} utils.Response{data=dto.TimeEntryDto} // @Failure 400 {object} utils.Response{error=utils.ErrorInfo} // @Failure 401 {object} utils.Response{error=utils.ErrorInfo} // @Failure 404 {object} utils.Response{error=utils.ErrorInfo} // @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(), models.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) } // GetTimeEntriesByUserID handles GET /time-entries/user/:userId // // @Summary Get time entries by user ID // @Description Get a list of time entries for a specific user // @Tags time-entries // @Accept json // @Produce json // @Security BearerAuth // @Param userId path string true "User ID" // @Success 200 {object} utils.Response{data=[]dto.TimeEntryDto} // @Failure 400 {object} utils.Response{error=utils.ErrorInfo} // @Failure 401 {object} utils.Response{error=utils.ErrorInfo} // @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(), models.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) } // GetMyTimeEntries handles GET /time-entries/me // // @Summary Get current user's time entries // @Description Get a list of time entries for the currently authenticated user // @Tags time-entries // @Accept json // @Produce json // @Security BearerAuth // @Success 200 {object} utils.Response{data=[]dto.TimeEntryDto} // @Failure 401 {object} utils.Response{error=utils.ErrorInfo} // @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(), models.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) } // GetTimeEntriesByProjectID handles GET /time-entries/project/:projectId // // @Summary Get time entries by project ID // @Description Get a list of time entries for a specific project // @Tags time-entries // @Accept json // @Produce json // @Security BearerAuth // @Param projectId path string true "Project ID" // @Success 200 {object} utils.Response{data=[]dto.TimeEntryDto} // @Failure 400 {object} utils.Response{error=utils.ErrorInfo} // @Failure 401 {object} utils.Response{error=utils.ErrorInfo} // @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 projectIDStr := c.Param("projectId") projectID, err := ulid.Parse(projectIDStr) if err != nil { utils.BadRequestResponse(c, "Invalid project ID format") return } // Get time entries from the database timeEntries, err := models.GetTimeEntriesByProjectID(c.Request.Context(), models.FromULID(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) } // GetTimeEntriesByDateRange handles GET /time-entries/range // // @Summary Get time entries by date range // @Description Get a list of time entries within a specific date range // @Tags time-entries // @Accept json // @Produce json // @Security BearerAuth // @Param start query string true "Start date (ISO 8601 format)" // @Param end query string true "End date (ISO 8601 format)" // @Success 200 {object} utils.Response{data=[]dto.TimeEntryDto} // @Failure 400 {object} utils.Response{error=utils.ErrorInfo} // @Failure 401 {object} utils.Response{error=utils.ErrorInfo} // @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) } // CreateTimeEntry handles POST /time-entries // // @Summary Create a new time entry // @Description Create a new time entry // @Tags time-entries // @Accept json // @Produce json // @Security BearerAuth // @Param timeEntry body dto.TimeEntryCreateDto true "Time Entry data" // @Success 201 {object} utils.Response{data=dto.TimeEntryDto} // @Failure 400 {object} utils.Response{error=utils.ErrorInfo} // @Failure 401 {object} utils.Response{error=utils.ErrorInfo} // @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) } // UpdateTimeEntry handles PUT /time-entries/:id // // @Summary Update a time entry // @Description Update an existing time entry // @Tags time-entries // @Accept json // @Produce json // @Security BearerAuth // @Param id path string true "Time Entry ID" // @Param timeEntry body dto.TimeEntryUpdateDto true "Time Entry data" // @Success 200 {object} utils.Response{data=dto.TimeEntryDto} // @Failure 400 {object} utils.Response{error=utils.ErrorInfo} // @Failure 401 {object} utils.Response{error=utils.ErrorInfo} // @Failure 404 {object} utils.Response{error=utils.ErrorInfo} // @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 := ulid.Parse(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 } // Set ID from URL timeEntryUpdateDTO.ID = id.String() // Convert DTO to model timeEntryUpdate, err := convertUpdateTimeEntryDTOToModel(timeEntryUpdateDTO) 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) } // DeleteTimeEntry handles DELETE /time-entries/:id // // @Summary Delete a time entry // @Description Delete a time entry by its ID // @Tags time-entries // @Accept json // @Produce json // @Security BearerAuth // @Param id path string true "Time Entry ID" // @Success 204 {object} utils.Response // @Failure 400 {object} utils.Response{error=utils.ErrorInfo} // @Failure 401 {object} utils.Response{error=utils.ErrorInfo} // @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(), models.FromULID(id)) if err != nil { utils.InternalErrorResponse(c, "Error deleting time entry: "+err.Error()) return } utils.SuccessResponse(c, http.StatusNoContent, nil) } // Helper functions for DTO conversion func convertTimeEntryToDTO(timeEntry *models.TimeEntry) dto.TimeEntryDto { return dto.TimeEntryDto{ ID: timeEntry.ID.String(), CreatedAt: timeEntry.CreatedAt, UpdatedAt: timeEntry.UpdatedAt, UserID: int(timeEntry.UserID.Time()), // Simplified conversion ProjectID: int(timeEntry.ProjectID.Time()), // Simplified conversion ActivityID: int(timeEntry.ActivityID.Time()), // Simplified conversion Start: timeEntry.Start, End: timeEntry.End, Description: timeEntry.Description, Billable: timeEntry.Billable, } } func convertCreateTimeEntryDTOToModel(dto dto.TimeEntryCreateDto) (models.TimeEntryCreate, error) { // Convert IDs from int to ULID (this is a simplification, adjust as needed) userID, err := idToULID(dto.UserID) if err != nil { return models.TimeEntryCreate{}, fmt.Errorf("invalid user ID: %w", err) } projectID, err := idToULID(dto.ProjectID) if err != nil { return models.TimeEntryCreate{}, fmt.Errorf("invalid project ID: %w", err) } activityID, err := idToULID(dto.ActivityID) if err != nil { return models.TimeEntryCreate{}, fmt.Errorf("invalid activity ID: %w", err) } return models.TimeEntryCreate{ UserID: models.FromULID(userID), ProjectID: models.FromULID(projectID), ActivityID: models.FromULID(activityID), Start: dto.Start, End: dto.End, Description: dto.Description, Billable: dto.Billable, }, nil } func convertUpdateTimeEntryDTOToModel(dto dto.TimeEntryUpdateDto) (models.TimeEntryUpdate, error) { id, _ := ulid.Parse(dto.ID) update := models.TimeEntryUpdate{ ID: models.FromULID(id), } if dto.UserID != nil { userID, err := idToULID(*dto.UserID) if err != nil { return models.TimeEntryUpdate{}, fmt.Errorf("invalid user ID: %w", err) } wrappedID := models.FromULID(userID) update.UserID = &wrappedID } if dto.ProjectID != nil { projectID, err := idToULID(*dto.ProjectID) if err != nil { return models.TimeEntryUpdate{}, fmt.Errorf("invalid project ID: %w", err) } wrappedProjectID := models.FromULID(projectID) update.ProjectID = &wrappedProjectID } if dto.ActivityID != nil { activityID, err := idToULID(*dto.ActivityID) if err != nil { return models.TimeEntryUpdate{}, fmt.Errorf("invalid activity ID: %w", err) } wrappedActivityID := models.FromULID(activityID) update.ActivityID = &wrappedActivityID } if dto.Start != nil { update.Start = dto.Start } if dto.End != nil { update.End = dto.End } if dto.Description != nil { update.Description = dto.Description } if dto.Billable != nil { update.Billable = dto.Billable } return update, nil } // Helper function to convert ID from int to ULID func idToULID(id int) (ulid.ULID, error) { // This is a simplification, in a real application you would need to // fetch the actual ULID from the database or use a proper conversion method // For now, we'll create a deterministic ULID based on the int value entropy := ulid.Monotonic(nil, 0) timestamp := uint64(id) return ulid.MustNew(timestamp, entropy), nil }