tickets.go 11 KB

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