tickets.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. package handlers
  2. import (
  3. "fmt"
  4. "net/http"
  5. "strconv"
  6. "time"
  7. "git.linuxforward.com/byop/byop-engine/dbstore"
  8. "git.linuxforward.com/byop/byop-engine/models"
  9. "github.com/gin-gonic/gin"
  10. "github.com/go-playground/validator/v10"
  11. )
  12. // TicketHandler handles ticket-related operations
  13. type TicketHandler struct {
  14. Store dbstore.Store // Use the defined interface
  15. Validate *validator.Validate // For request validation
  16. }
  17. // NewTicketHandler creates a new TicketHandler
  18. func NewTicketHandler(store dbstore.Store) *TicketHandler {
  19. return &TicketHandler{
  20. Store: store,
  21. Validate: validator.New(),
  22. }
  23. }
  24. // RegisterRoutes registers routes for ticket operations
  25. func (h *TicketHandler) RegisterRoutes(r *gin.RouterGroup) {
  26. r.GET("/", h.ListTickets)
  27. r.POST("/", h.CreateTicket)
  28. r.GET("/:id", h.GetTicket)
  29. r.PUT("/:id", h.UpdateTicket)
  30. r.GET("/:id/comments", h.GetTicketComments)
  31. r.POST("/:id/comments", h.AddTicketComment)
  32. r.POST("/:id/resolve", h.ResolveTicket)
  33. }
  34. // ListTickets returns all tickets
  35. func (h *TicketHandler) ListTickets(c *gin.Context) {
  36. ctx := c.Request.Context()
  37. tickets, err := h.Store.GetTickets(ctx)
  38. if err != nil {
  39. appErr := models.NewErrInternalServer("list_tickets_failed", fmt.Errorf("Failed to list tickets: %w", err))
  40. models.RespondWithError(c, appErr)
  41. return
  42. }
  43. if tickets == nil {
  44. tickets = []models.Ticket{} // Return empty slice instead of null
  45. }
  46. c.JSON(http.StatusOK, tickets)
  47. }
  48. // CreateTicketInput defines the input for creating a ticket
  49. type CreateTicketInput struct {
  50. Title string `json:"title" validate:"required,min=3,max=255"`
  51. Description string `json:"description" validate:"required,min=10"`
  52. ClientID int `json:"client_id" validate:"required,gt=0"` // Assuming ClientID is mandatory for a ticket
  53. UserID *int `json:"user_id,omitempty" validate:"omitempty,gt=0"`
  54. Priority string `json:"priority" validate:"omitempty,oneof=low medium high critical"`
  55. }
  56. // CreateTicket creates a new ticket
  57. func (h *TicketHandler) CreateTicket(c *gin.Context) {
  58. ctx := c.Request.Context()
  59. var input CreateTicketInput
  60. if err := c.ShouldBindJSON(&input); err != nil {
  61. appErr := models.NewErrValidation("invalid_ticket_input", map[string]string{"body": "Invalid request body"}, err)
  62. models.RespondWithError(c, appErr)
  63. return
  64. }
  65. if err := h.Validate.StructCtx(ctx, input); err != nil {
  66. errors := models.ExtractValidationErrors(err)
  67. appErr := models.NewErrValidation("ticket_validation_failed", errors, err)
  68. models.RespondWithError(c, appErr)
  69. return
  70. }
  71. // authUserID, _ := ctx.Value("userID").(int) // Example: Get authenticated user ID
  72. ticket := models.Ticket{
  73. Title: input.Title,
  74. Description: input.Description,
  75. ClientID: input.ClientID,
  76. UserID: input.UserID,
  77. Status: models.TicketStatusOpen,
  78. Priority: input.Priority,
  79. // CreatedAt and UpdatedAt will be set by the store or DB
  80. }
  81. if ticket.Priority == "" {
  82. ticket.Priority = models.TicketPriorityMedium // Default priority
  83. }
  84. if err := h.Store.CreateTicket(ctx, &ticket); err != nil {
  85. appErr := models.NewErrInternalServer("create_ticket_failed", fmt.Errorf("Failed to create ticket: %w", err))
  86. models.RespondWithError(c, appErr)
  87. return
  88. }
  89. c.JSON(http.StatusCreated, ticket)
  90. }
  91. // GetTicket returns a specific ticket
  92. func (h *TicketHandler) GetTicket(c *gin.Context) {
  93. ctx := c.Request.Context()
  94. idStr := c.Param("id")
  95. id, err := strconv.ParseInt(idStr, 10, 64)
  96. if err != nil {
  97. appErr := models.NewErrValidation("invalid_ticket_id_format", map[string]string{"id": "Invalid ticket ID format"}, err)
  98. models.RespondWithError(c, appErr)
  99. return
  100. }
  101. ticket, err := h.Store.GetTicketByID(ctx, int(id))
  102. if err != nil {
  103. if models.IsErrNotFound(err) {
  104. appErr := models.NewErrNotFound("ticket_not_found", fmt.Errorf("Ticket with ID %d not found: %w", id, err))
  105. models.RespondWithError(c, appErr)
  106. return
  107. }
  108. appErr := models.NewErrInternalServer("get_ticket_failed", fmt.Errorf("Failed to get ticket %d: %w", id, err))
  109. models.RespondWithError(c, appErr)
  110. return
  111. }
  112. c.JSON(http.StatusOK, ticket)
  113. }
  114. // UpdateTicketInput defines the input for updating a ticket
  115. type UpdateTicketInput struct {
  116. Title *string `json:"title,omitempty" validate:"omitempty,min=3,max=255"`
  117. Description *string `json:"description,omitempty" validate:"omitempty,min=10"`
  118. Priority *string `json:"priority,omitempty" validate:"omitempty,oneof=low medium high critical"`
  119. Status *string `json:"status,omitempty" validate:"omitempty,oneof=open in_progress resolved closed"`
  120. AssignedTo *int `json:"assigned_to,omitempty" validate:"omitempty,gt=0"`
  121. }
  122. // UpdateTicket updates a ticket
  123. func (h *TicketHandler) UpdateTicket(c *gin.Context) {
  124. ctx := c.Request.Context()
  125. idStr := c.Param("id")
  126. id, err := strconv.ParseInt(idStr, 10, 64)
  127. if err != nil {
  128. appErr := models.NewErrValidation("invalid_ticket_id_format", map[string]string{"id": "Invalid ticket ID format"}, err)
  129. models.RespondWithError(c, appErr)
  130. return
  131. }
  132. var input UpdateTicketInput
  133. if err := c.ShouldBindJSON(&input); err != nil {
  134. appErr := models.NewErrValidation("invalid_update_ticket_input", map[string]string{"body": "Invalid request body"}, err)
  135. models.RespondWithError(c, appErr)
  136. return
  137. }
  138. if err := h.Validate.StructCtx(ctx, input); err != nil {
  139. errors := models.ExtractValidationErrors(err)
  140. appErr := models.NewErrValidation("update_ticket_validation_failed", errors, err)
  141. models.RespondWithError(c, appErr)
  142. return
  143. }
  144. ticket, err := h.Store.GetTicketByID(ctx, int(id))
  145. if err != nil {
  146. if models.IsErrNotFound(err) {
  147. appErr := models.NewErrNotFound("ticket_not_found_for_update", fmt.Errorf("Ticket with ID %d not found for update: %w", id, err))
  148. models.RespondWithError(c, appErr)
  149. return
  150. }
  151. appErr := models.NewErrInternalServer("get_ticket_for_update_failed", fmt.Errorf("Failed to get ticket %d for update: %w", id, err))
  152. models.RespondWithError(c, appErr)
  153. return
  154. }
  155. // Apply updates
  156. updated := false
  157. if input.Title != nil {
  158. ticket.Title = *input.Title
  159. updated = true
  160. }
  161. if input.Description != nil {
  162. ticket.Description = *input.Description
  163. updated = true
  164. }
  165. if input.Priority != nil {
  166. ticket.Priority = *input.Priority
  167. updated = true
  168. }
  169. if input.Status != nil {
  170. ticket.Status = *input.Status
  171. updated = true
  172. }
  173. if input.AssignedTo != nil {
  174. ticket.AssignedTo = input.AssignedTo
  175. updated = true
  176. }
  177. if !updated {
  178. c.JSON(http.StatusOK, ticket) // No changes, return current ticket
  179. return
  180. }
  181. if err := h.Store.UpdateTicket(ctx, ticket); err != nil {
  182. appErr := models.NewErrInternalServer("update_ticket_failed", fmt.Errorf("Failed to update ticket %d: %w", id, err))
  183. models.RespondWithError(c, appErr)
  184. return
  185. }
  186. c.JSON(http.StatusOK, ticket)
  187. }
  188. // GetTicketComments returns comments for a ticket
  189. func (h *TicketHandler) GetTicketComments(c *gin.Context) {
  190. ctx := c.Request.Context()
  191. idStr := c.Param("id")
  192. ticketID, err := strconv.ParseInt(idStr, 10, 64)
  193. if err != nil {
  194. appErr := models.NewErrValidation("invalid_ticket_id_for_comments", map[string]string{"id": "Invalid ticket ID format for comments"}, err)
  195. models.RespondWithError(c, appErr)
  196. return
  197. }
  198. comments, err := h.Store.GetTicketComments(ctx, int(ticketID))
  199. if err != nil {
  200. // If the error indicates the ticket itself was not found, that's a 404 for the ticket.
  201. // Otherwise, it's an internal error fetching comments.
  202. // Assuming GetTicketComments might return ErrNotFound if the ticket doesn't exist.
  203. if models.IsErrNotFound(err) { // This could be ambiguous: ticket not found OR no comments found and store treats it as not found.
  204. // To be more precise, one might first check if ticket exists, then fetch comments.
  205. // For now, assume this means ticket itself is not found.
  206. appErr := models.NewErrNotFound("ticket_not_found_for_comments", fmt.Errorf("Ticket with ID %d not found when fetching comments: %w", ticketID, err))
  207. models.RespondWithError(c, appErr)
  208. return
  209. }
  210. appErr := models.NewErrInternalServer("get_ticket_comments_failed", fmt.Errorf("Failed to get comments for ticket %d: %w", ticketID, err))
  211. models.RespondWithError(c, appErr)
  212. return
  213. }
  214. if comments == nil {
  215. comments = []models.TicketComment{} // Return empty slice
  216. }
  217. c.JSON(http.StatusOK, comments)
  218. }
  219. // AddTicketCommentInput defines the input for adding a comment
  220. type AddTicketCommentInput struct {
  221. Content string `json:"content" validate:"required,min=1"`
  222. // UserID will be taken from authenticated user context
  223. }
  224. // AddTicketComment adds a comment to a ticket
  225. func (h *TicketHandler) AddTicketComment(c *gin.Context) {
  226. ctx := c.Request.Context()
  227. idStr := c.Param("id")
  228. ticketID, err := strconv.ParseInt(idStr, 10, 64)
  229. if err != nil {
  230. appErr := models.NewErrValidation("invalid_ticket_id_for_add_comment", map[string]string{"id": "Invalid ticket ID format for adding comment"}, err)
  231. models.RespondWithError(c, appErr)
  232. return
  233. }
  234. var input AddTicketCommentInput
  235. if err := c.ShouldBindJSON(&input); err != nil {
  236. appErr := models.NewErrValidation("invalid_comment_input", map[string]string{"body": "Invalid request body for comment"}, err)
  237. models.RespondWithError(c, appErr)
  238. return
  239. }
  240. if err := h.Validate.StructCtx(ctx, input); err != nil {
  241. errors := models.ExtractValidationErrors(err)
  242. appErr := models.NewErrValidation("comment_validation_failed", errors, err)
  243. models.RespondWithError(c, appErr)
  244. return
  245. }
  246. // Get authenticated user ID (placeholder - replace with actual auth logic)
  247. authUserID := 1 // Example: Assume user ID 1 is authenticated
  248. // if !ok || authUserID == 0 {
  249. // appErr := models.NewErrUnauthorized("user_not_authenticated_for_comment", fmt.Errorf("User must be authenticated to comment"))
  250. // models.RespondWithError(c, appErr)
  251. // return
  252. // }
  253. comment := models.TicketComment{
  254. TicketID: int(ticketID),
  255. UserID: authUserID, // Set from authenticated user
  256. Content: input.Content,
  257. }
  258. if err := h.Store.CreateTicketComment(ctx, &comment); err != nil {
  259. // Check if the error is because the ticket doesn't exist (e.g., foreign key violation)
  260. if models.IsErrForeignKeyViolation(err) || models.IsErrNotFound(err) { // IsErrNotFound might be returned by store if ticket check fails
  261. appErr := models.NewErrNotFound("ticket_not_found_for_new_comment", fmt.Errorf("Ticket with ID %d not found, cannot add comment: %w", ticketID, err))
  262. models.RespondWithError(c, appErr)
  263. return
  264. }
  265. appErr := models.NewErrInternalServer("add_comment_failed", fmt.Errorf("Failed to add comment to ticket %d: %w", ticketID, err))
  266. models.RespondWithError(c, appErr)
  267. return
  268. }
  269. c.JSON(http.StatusCreated, comment)
  270. }
  271. // ResolveTicket resolves a ticket
  272. func (h *TicketHandler) ResolveTicket(c *gin.Context) {
  273. ctx := c.Request.Context()
  274. idStr := c.Param("id")
  275. id, err := strconv.ParseInt(idStr, 10, 64)
  276. if err != nil {
  277. appErr := models.NewErrValidation("invalid_ticket_id_for_resolve", map[string]string{"id": "Invalid ticket ID format for resolving"}, err)
  278. models.RespondWithError(c, appErr)
  279. return
  280. }
  281. ticket, err := h.Store.GetTicketByID(ctx, int(id))
  282. if err != nil {
  283. if models.IsErrNotFound(err) {
  284. appErr := models.NewErrNotFound("ticket_not_found_for_resolve", fmt.Errorf("Ticket with ID %d not found for resolve: %w", id, err))
  285. models.RespondWithError(c, appErr)
  286. return
  287. }
  288. appErr := models.NewErrInternalServer("get_ticket_for_resolve_failed", fmt.Errorf("Failed to get ticket %d for resolve: %w", id, err))
  289. models.RespondWithError(c, appErr)
  290. return
  291. }
  292. if ticket.Status == models.TicketStatusResolved || ticket.Status == models.TicketStatusClosed {
  293. appErr := models.NewErrValidation("ticket_already_resolved_or_closed", map[string]string{"status": fmt.Sprintf("Ticket is already %s", ticket.Status)}, nil)
  294. models.RespondWithError(c, appErr)
  295. return
  296. }
  297. ticket.Status = models.TicketStatusResolved
  298. now := time.Now()
  299. ticket.ResolvedAt = &now
  300. if err := h.Store.UpdateTicket(ctx, ticket); err != nil {
  301. appErr := models.NewErrInternalServer("resolve_ticket_failed", fmt.Errorf("Failed to resolve ticket %d: %w", id, err))
  302. models.RespondWithError(c, appErr)
  303. return
  304. }
  305. c.JSON(http.StatusOK, ticket)
  306. }