package api import ( "net/http" "time" "reanimator/internal/domain" "reanimator/internal/repository/registry" "reanimator/internal/repository/tickets" ) type TicketDependencies struct { Tickets *tickets.TicketRepository Assets *registry.AssetRepository } type ticketHandlers struct { deps TicketDependencies } func RegisterTicketRoutes(mux *http.ServeMux, deps TicketDependencies) { h := ticketHandlers{deps: deps} mux.HandleFunc("/connectors/tickets/sync", h.handleTicketSync) } type ticketSyncRequest struct { Source string `json:"source"` Tickets []ticketSyncEntry `json:"tickets"` } type ticketSyncEntry struct { ExternalID string `json:"external_id"` Title string `json:"title"` Status string `json:"status"` OpenedAt *string `json:"opened_at"` ClosedAt *string `json:"closed_at"` URL *string `json:"url"` AssetID int64 `json:"asset_id"` } func (h ticketHandlers) handleTicketSync(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } if h.deps.Tickets == nil { writeError(w, http.StatusInternalServerError, "tickets unavailable") return } var req ticketSyncRequest if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "invalid json") return } if req.Source == "" { writeError(w, http.StatusBadRequest, "source is required") return } if len(req.Tickets) == 0 { writeError(w, http.StatusBadRequest, "tickets is required") return } tx, err := h.deps.Tickets.BeginTx(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "ticket sync failed") return } defer func() { _ = tx.Rollback() }() for _, entry := range req.Tickets { if entry.ExternalID == "" { writeError(w, http.StatusBadRequest, "tickets.external_id is required") return } if entry.Title == "" { writeError(w, http.StatusBadRequest, "tickets.title is required") return } if entry.Status == "" { writeError(w, http.StatusBadRequest, "tickets.status is required") return } if entry.AssetID <= 0 { writeError(w, http.StatusBadRequest, "tickets.asset_id is required") return } if h.deps.Assets != nil { if _, err := h.deps.Assets.Get(r.Context(), entry.AssetID); err != nil { if err == registry.ErrNotFound { writeError(w, http.StatusBadRequest, "asset_id not found") return } writeError(w, http.StatusInternalServerError, "asset lookup failed") return } } openedAt, err := parseRFC3339(entry.OpenedAt) if err != nil { writeError(w, http.StatusBadRequest, "tickets.opened_at must be RFC3339") return } closedAt, err := parseRFC3339(entry.ClosedAt) if err != nil { writeError(w, http.StatusBadRequest, "tickets.closed_at must be RFC3339") return } id, err := h.deps.Tickets.Upsert(r.Context(), tx, domain.Ticket{ Source: req.Source, ExternalID: entry.ExternalID, Title: entry.Title, Status: entry.Status, OpenedAt: openedAt, ClosedAt: closedAt, URL: entry.URL, }) if err != nil { writeError(w, http.StatusInternalServerError, "ticket sync failed") return } if err := h.deps.Tickets.LinkToAsset(r.Context(), tx, id, entry.AssetID); err != nil { writeError(w, http.StatusInternalServerError, "ticket link failed") return } } if err := tx.Commit(); err != nil { writeError(w, http.StatusInternalServerError, "ticket sync failed") return } writeJSON(w, http.StatusOK, map[string]any{ "synced": len(req.Tickets), }) } func parseRFC3339(value *string) (*time.Time, error) { if value == nil || *value == "" { return nil, nil } parsed, err := time.Parse(time.RFC3339, *value) if err != nil { return nil, err } return &parsed, nil }