Initial commit of Descrybe v2 without local scratch artifacts.

Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
2026-08-09 22:47:43 +02:00
commit 8580c996c3
1285 changed files with 325780 additions and 0 deletions
@@ -0,0 +1,264 @@
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, "", ""
}