265 lines
8.4 KiB
Go
265 lines
8.4 KiB
Go
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, "", ""
|
||
|
|
}
|