package httpapi import ( "fmt" "net/http" "strconv" "strings" "time" "github.com/descrybe/descrybe-v2/apps/api/internal/feeds" "github.com/descrybe/descrybe-v2/apps/api/internal/marketing" "github.com/go-chi/chi/v5" "github.com/google/uuid" ) // presentV1ExportFeed shapes an export feed for the legacy public API // (presentExportFeed + v2 public_token extras). func presentV1ExportFeed(item map[string]any) map[string]any { if item == nil { return map[string]any{} } out := map[string]any{ "id": item["id"], "name": item["name"], "format": item["format"], "root_xpath": nil, "item_xpath": nil, "mappings": map[string]any{}, "structure": nil, "last_generated_at": item["last_generated_at"], "created_at": item["created_at"], "updated_at": item["updated_at"], "public_token": item["public_token"], "is_active": item["is_active"], "source_feed_id": item["source_feed_id"], } if v, ok := item["template"]; ok && v != nil { out["structure"] = v } if v, ok := item["filters"]; ok && v != nil { out["filters"] = v } if token, _ := item["public_token"].(string); token != "" { format := strings.ToLower(strings.TrimSpace(fmt.Sprint(item["format"]))) ext := "xml" if format == "csv" { ext = "csv" } path := "/api/public/export-feeds/" + token + "." + ext // Only advertise the matching extension — wrong-format URLs 404 and must not // be suggested (also avoids encouraging token-existence probes). out["public_urls"] = map[string]string{ ext: path, "token": path, } } return out } func (s *Server) handleV1ListExportFeeds(w http.ResponseWriter, r *http.Request) { cid, _ := CompanyIDFromContext(r.Context()) page, limit, offset := ParsePageLimit(r) items, total, err := s.Feeds.ListExportFeeds(r.Context(), cid, limit, offset) if err != nil { Error(w, http.StatusInternalServerError, "list failed") return } out := make([]map[string]any, 0, len(items)) for _, item := range items { out = append(out, presentV1ExportFeed(item)) } v1OK(w, http.StatusOK, out, map[string]any{ "page": page, "limit": limit, "total": total, }) } func (s *Server) handleV1CreateExportFeed(w http.ResponseWriter, r *http.Request) { cid, _ := CompanyIDFromContext(r.Context()) var body struct { Name string `json:"name"` Format string `json:"format"` SourceFeedID *string `json:"source_feed_id"` Template any `json:"template"` Structure any `json:"structure"` Mappings any `json:"mappings"` Filters any `json:"filters"` RootXpath *string `json:"root_xpath"` ItemXpath *string `json:"item_xpath"` OutputPath *string `json:"output_path"` AttributeExportMode any `json:"attribute_export_mode"` } if err := DecodeJSON(r, &body); err != nil { v1Err(w, http.StatusBadRequest, "validation_error", "invalid json") return } if strings.TrimSpace(body.Name) == "" || strings.TrimSpace(body.Format) == "" { v1Err(w, http.StatusBadRequest, "validation_error", "Missing required fields: name, format") return } tpl := body.Template if tpl == nil { tpl = body.Structure } if tpl == nil && body.Mappings != nil { tpl = map[string]any{"mappings": body.Mappings} } if tpl == nil && (body.RootXpath != nil || body.ItemXpath != nil) { m := map[string]any{} if body.RootXpath != nil { m["root"] = *body.RootXpath } if body.ItemXpath != nil { m["item"] = *body.ItemXpath } tpl = m } item, err := s.Feeds.CreateExportFeed(r.Context(), cid, feeds.CreateExportInput{ Name: body.Name, SourceFeedID: body.SourceFeedID, Format: body.Format, Template: tpl, Filters: body.Filters, }) if err != nil { if msg, ok := feeds.ClientError(err); ok { v1Err(w, http.StatusBadRequest, "validation_error", msg) return } ClientOrLog(w, http.StatusBadRequest, "could not create export feed", err, feeds.ClientError) return } v1OK(w, http.StatusCreated, presentV1ExportFeed(item), nil) } func (s *Server) handleV1GenerateExportFeed(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 } feed, err := s.Feeds.GetExportFeed(r.Context(), cid, id) if err != nil { v1Err(w, http.StatusNotFound, "not_found", "Export feed not found") return } result, err := s.Feeds.GenerateExportFeed(r.Context(), cid, id) if err != nil { if msg, ok := feeds.ClientError(err); ok { v1Err(w, http.StatusBadRequest, "generation_failed", msg) return } v1Err(w, http.StatusInternalServerError, "generation_failed", "Failed to generate export feed") return } format := strings.ToLower(strings.TrimSpace(fmt.Sprint(feed["format"]))) if format == "" { format = strings.ToLower(strings.TrimSpace(fmt.Sprint(result["format"]))) } ext := "xml" if format == "csv" { ext = "csv" } token, _ := feed["public_token"].(string) downloadURL := fmt.Sprintf("/api/export-feeds/%s/%s", id.String(), ext) if token != "" { downloadURL = fmt.Sprintf("/api/public/export-feeds/%s.%s", token, ext) } v1OK(w, http.StatusOK, map[string]any{ "generated": true, "format": format, "filePath": nil, "downloadUrl": downloadURL, "products_exported": result["products_exported"], "last_generated_at": result["last_generated_at"], "status": result["status"], }, nil) } // handleV1ListCampaigns is the legacy alias for GET /marketing/calendar // (seasonal export prep — not email /api/campaigns). func (s *Server) handleV1ListCampaigns(w http.ResponseWriter, r *http.Request) { payload, status, errCode, errMsg := s.v1MarketingCalendar(r) if errMsg != "" { v1Err(w, status, errCode, errMsg) return } v1OK(w, http.StatusOK, payload, nil) } // handleV1PrepareCampaign is the legacy alias for POST /marketing/calendar/prepare. func (s *Server) handleV1PrepareCampaign(w http.ResponseWriter, r *http.Request) { cid, _ := CompanyIDFromContext(r.Context()) var body struct { PresetID string `json:"preset_id"` Year int `json:"year"` Format string `json:"format"` ForceNew bool `json:"force_new"` } if err := DecodeJSON(r, &body); err != nil { v1Err(w, http.StatusBadRequest, "validation_error", "invalid json") return } campaign, err := s.marketingService().PrepareCampaign(r.Context(), cid, marketing.PrepareInput{ PresetID: marketing.PresetID(body.PresetID), Year: body.Year, Format: body.Format, ForceNew: body.ForceNew, }) if err != nil { if msg, ok := marketing.ClientError(err); ok { v1Err(w, http.StatusBadRequest, "validation_error", msg) return } ClientOrLog(w, http.StatusBadRequest, "could not prepare campaign", err, marketing.ClientError) return } status := http.StatusOK if campaign.Created { status = http.StatusCreated } v1OK(w, status, map[string]any{ "preset_id": campaign.PresetID, "name": campaign.Name, "start_date": campaign.StartDate, "end_date": campaign.EndDate, "year": campaign.Year, "export_feed_id": campaign.ExportFeedID, "export_feed_name": campaign.ExportFeedName, "created": campaign.Created, }, nil) } func (s *Server) v1MarketingCalendar(r *http.Request) (payload map[string]any, status int, errCode, errMsg string) { cid, _ := CompanyIDFromContext(r.Context()) year := time.Now().UTC().Year() if y := r.URL.Query().Get("year"); y != "" { parsed, err := strconv.Atoi(y) if err != nil || parsed < 2000 || parsed > 2100 { return nil, http.StatusBadRequest, "validation_error", "Invalid year" } year = parsed } prepared, err := s.marketingService().ListPreparedCampaigns(r.Context(), cid) if err != nil { return nil, http.StatusInternalServerError, "list_failed", "list failed" } preparedOut := make([]map[string]any, 0, len(prepared)) for _, c := range prepared { preparedOut = append(preparedOut, map[string]any{ "preset_id": c.PresetID, "name": c.Name, "start_date": c.StartDate, "end_date": c.EndDate, "year": c.Year, "export_feed_id": c.ExportFeedID, "export_feed_name": c.ExportFeedName, }) } return map[string]any{ "year": year, "presets": marketing.ListPresets(year), "prepared": preparedOut, }, http.StatusOK, "", "" }