252 lines
8.1 KiB
Go
252 lines
8.1 KiB
Go
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)
|
||
|
|
}
|