package httpapi import ( "net/http" "strconv" "strings" "github.com/descrybe/descrybe-v2/apps/api/internal/catalog" "github.com/descrybe/descrybe-v2/apps/api/internal/feeds" "github.com/go-chi/chi/v5" "github.com/google/uuid" ) // Legacy public API envelope helpers (match Next.js ok()/err() shapes). func v1OK(w http.ResponseWriter, status int, data any, meta map[string]any) { body := map[string]any{"data": data} if meta != nil { body["meta"] = meta } JSON(w, status, body) } // OK is an alias of v1OK for legacy-shaped list/create handlers. func OK(w http.ResponseWriter, status int, data any, meta map[string]any) { v1OK(w, status, data, meta) } // v1Err writes the legacy coded envelope via CodedError so messages respect Accept-Language. func v1Err(w http.ResponseWriter, status int, code, message string) { CodedError(w, status, code, message) } func (s *Server) handleV1ListFeeds(w http.ResponseWriter, r *http.Request) { cid, _ := CompanyIDFromContext(r.Context()) page, limit, offset := ParsePageLimit(r) items, total, activeTotal, mappedTotal, err := s.Feeds.List(r.Context(), cid, limit, offset, QuerySearch(r)) if err != nil { v1Err(w, http.StatusInternalServerError, "internal_error", "list failed") return } products, err := s.Feeds.CompanyProductTotals(r.Context(), cid) if err != nil { v1Err(w, http.StatusInternalServerError, "internal_error", "list failed") return } v1OK(w, http.StatusOK, feeds.PresentFeeds(items), map[string]any{ "page": page, "limit": limit, "total": total, "totalPages": (int(total) + limit - 1) / max(limit, 1), "offset": offset, "active_total": activeTotal, "mapped_total": mappedTotal, "product_total": products.Total, "processed_total": products.Processed, "unprocessed_total": products.Unprocessed, }) } func (s *Server) handleV1CreateFeed(w http.ResponseWriter, r *http.Request) { cid, _ := CompanyIDFromContext(r.Context()) uid, _ := UserIDFromContext(r.Context()) ct := strings.ToLower(strings.TrimSpace(r.Header.Get("Content-Type"))) if strings.HasPrefix(ct, "multipart/form-data") { s.createV1FeedFromMultipart(w, r, cid, uid) return } var body struct { Name string `json:"name"` URL string `json:"url"` ItemPath string `json:"item_path"` FeedType string `json:"feed_type"` SyncIntervalMinutes int `json:"sync_interval_minutes"` SyncFrequency int `json:"sync_frequency"` // legacy hours IsActive *bool `json:"is_active"` } if err := DecodeJSON(r, &body); err != nil { v1Err(w, http.StatusBadRequest, "validation_error", "invalid json") return } name := strings.TrimSpace(body.Name) itemPath := strings.TrimSpace(body.ItemPath) url := strings.TrimSpace(body.URL) if name == "" { v1Err(w, http.StatusBadRequest, "validation_error", "Missing required fields: name, item_path") return } // Legacy requires item_path; dual-support allows name+url (or multipart file) without it. if itemPath == "" && url == "" { v1Err(w, http.StatusBadRequest, "validation_error", "Missing required fields: name, item_path") return } item, err := s.Feeds.Create(r.Context(), cid, feeds.CreateInput{ Name: name, URL: url, ItemPath: itemPath, FeedType: body.FeedType, SyncIntervalMinutes: body.SyncIntervalMinutes, SyncFrequencyHours: body.SyncFrequency, }) if err != nil { if msg, ok := feeds.ClientError(err); ok { v1Err(w, http.StatusBadRequest, "validation_error", msg) return } v1Err(w, http.StatusInternalServerError, "internal_error", "could not create feed") return } // Optional is_active flip after create (legacy field; maps to status=active). if body.IsActive != nil && *body.IsActive { if fid, perr := parseMapUUID(item["id"]); perr == nil { if updated, uerr := s.Feeds.Update(r.Context(), cid, fid, map[string]any{"status": "active"}); uerr == nil { item = updated } } } v1OK(w, http.StatusCreated, feeds.PresentFeed(item), nil) } func (s *Server) createV1FeedFromMultipart(w http.ResponseWriter, r *http.Request, cid, uid uuid.UUID) { if err := r.ParseMultipartForm(catalogMaxUpload); err != nil { v1Err(w, http.StatusBadRequest, "validation_error", "invalid multipart form") return } name := strings.TrimSpace(r.FormValue("name")) url := strings.TrimSpace(r.FormValue("url")) itemPath := strings.TrimSpace(r.FormValue("item_path")) feedType := strings.TrimSpace(r.FormValue("feed_type")) interval, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("sync_interval_minutes"))) freq, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("sync_frequency"))) file, header, fileErr := r.FormFile("file") var options map[string]any if fileErr == nil { defer file.Close() meta, err := s.Catalog.SaveUpload( r.Context(), cid, uid, s.Config.UploadDir, header.Filename, header.Header.Get("Content-Type"), "feed", file, ) if err != nil { if msg, ok := catalog.ClientError(err); ok { v1Err(w, http.StatusBadRequest, "validation_error", msg) return } v1Err(w, http.StatusBadRequest, "validation_error", "could not save upload") return } pathStr, _ := meta["path"].(string) fileID, _ := meta["id"].(string) fileName, _ := meta["name"].(string) options = map[string]any{ "source_path": pathStr, "source_file_id": fileID, "source_filename": fileName, "source_kind": "csv", } if feedType == "" { feedType = "csv" } if fid, err := uuid.Parse(fileID); err == nil { _, _ = s.Catalog.UpdateFileStatus(r.Context(), cid, fid, "uploaded", map[string]any{ "kind": "feed", "feed": true, "name": name, }) } } if name == "" { v1Err(w, http.StatusBadRequest, "validation_error", "Missing required fields: name, item_path") return } // Multipart CSV may omit item_path; JSON legacy requires it. Dual-support: file XOR item_path/url. if fileErr != nil && itemPath == "" && url == "" { v1Err(w, http.StatusBadRequest, "validation_error", "Missing required fields: name, item_path") return } item, err := s.Feeds.Create(r.Context(), cid, feeds.CreateInput{ Name: name, URL: url, ItemPath: itemPath, FeedType: feedType, SyncIntervalMinutes: interval, SyncFrequencyHours: freq, Options: options, }) if err != nil { if msg, ok := feeds.ClientError(err); ok { v1Err(w, http.StatusBadRequest, "validation_error", msg) return } v1Err(w, http.StatusInternalServerError, "internal_error", "could not create feed") return } v1OK(w, http.StatusCreated, feeds.PresentFeed(item), nil) } func (s *Server) handleV1GetFeed(w http.ResponseWriter, r *http.Request) { cid, _ := CompanyIDFromContext(r.Context()) id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { v1Err(w, http.StatusBadRequest, "validation_error", "Invalid id") return } item, err := s.Feeds.Get(r.Context(), cid, id) if err != nil { if feeds.IsNotFound(err) { v1Err(w, http.StatusNotFound, "not_found", "Not found") return } v1Err(w, http.StatusInternalServerError, "internal_error", "get failed") return } v1OK(w, http.StatusOK, feeds.PresentFeed(item), nil) } func (s *Server) handleV1SyncFeed(w http.ResponseWriter, r *http.Request) { cid, _ := CompanyIDFromContext(r.Context()) id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { v1Err(w, http.StatusBadRequest, "validation_error", "Invalid id") return } jobID, err := s.Feeds.EnqueueSync(r.Context(), cid, id) if err != nil { if feeds.IsNotFound(err) { v1Err(w, http.StatusNotFound, "not_found", "Feed not found") return } if msg, ok := feeds.ClientError(err); ok { v1Err(w, http.StatusBadRequest, "validation_error", msg) return } v1Err(w, http.StatusInternalServerError, "internal_error", "Failed to create sync job") return } if s.Jobs != nil { _ = s.Jobs.EnqueueFeedSyncJob(r.Context(), jobID) } // Legacy contract: 200 { data: { jobId } }. Dual-support also exposes job_id. // Job runs on the worker (SKIP LOCKED); poll dashboard GET .../sync-jobs/{jobID}. v1OK(w, http.StatusOK, map[string]any{ "jobId": jobID.String(), "job_id": jobID.String(), }, nil) }