2026-08-09 22:47:43 +02:00
|
|
|
package httpapi
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"errors"
|
|
|
|
|
"net/http"
|
|
|
|
|
|
|
|
|
|
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
|
|
|
|
|
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
|
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
|
"github.com/google/uuid"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// startProcessingJobRequest is the SPA/API body for POST /processing/jobs.
|
|
|
|
|
// ProcessingTypes is accepted because the dashboard sends fine-grained types
|
|
|
|
|
// alongside the coarse ProcessingType used by StartJob; DecodeJSON rejects unknowns.
|
|
|
|
|
type startProcessingJobRequest struct {
|
|
|
|
|
RawProductIDs []string `json:"raw_product_ids"`
|
|
|
|
|
ProcessingType string `json:"processing_type"`
|
|
|
|
|
ProcessingTypes []string `json:"processing_types"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleStartProcessingJob(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
cid, ok := CompanyIDFromContext(r.Context())
|
|
|
|
|
if !ok || cid == uuid.Nil {
|
|
|
|
|
Error(w, http.StatusUnauthorized, "unauthorized")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
uid, _ := UserIDFromContext(r.Context())
|
|
|
|
|
var body startProcessingJobRequest
|
|
|
|
|
if err := DecodeJSON(r, &body); err != nil {
|
|
|
|
|
Error(w, http.StatusBadRequest, "invalid json")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
ids := make([]uuid.UUID, 0, len(body.RawProductIDs))
|
|
|
|
|
for _, sID := range body.RawProductIDs {
|
|
|
|
|
id, err := uuid.Parse(sID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
Error(w, http.StatusBadRequest, "invalid raw_product_id")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
ids = append(ids, id)
|
|
|
|
|
}
|
|
|
|
|
jobs, err := s.Processing.StartJob(r.Context(), cid, uid, ids, body.ProcessingType)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if writePlanGate(w, err) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if errors.Is(err, processing.ErrRateLimited) {
|
|
|
|
|
Error(w, http.StatusTooManyRequests, err.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if msg, ok := processing.ClientError(err); ok {
|
|
|
|
|
Error(w, http.StatusBadRequest, msg)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
LogAndError(w, http.StatusBadRequest, "could not start processing job", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
for _, job := range jobs {
|
|
|
|
|
if err := s.Jobs.EnqueueProcessingJob(r.Context(), job.ID); err != nil {
|
|
|
|
|
Error(w, http.StatusInternalServerError, "enqueue failed")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
JSON(w, http.StatusAccepted, processing.FormatStartJobsResponse(jobs))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func planGateCode(err error) string {
|
|
|
|
|
switch {
|
|
|
|
|
case errors.Is(err, billing.ErrInsufficientCredits):
|
|
|
|
|
return "insufficient_credits"
|
|
|
|
|
case errors.Is(err, billing.ErrProductLimitExceeded):
|
|
|
|
|
return "product_limit"
|
|
|
|
|
case errors.Is(err, billing.ErrAIRequiresUpgrade):
|
|
|
|
|
return "ai_requires_upgrade"
|
|
|
|
|
case errors.Is(err, billing.ErrEPRELRequiresUpgrade):
|
|
|
|
|
return "eprel_requires_upgrade"
|
|
|
|
|
case errors.Is(err, billing.ErrFeatureDisabled):
|
|
|
|
|
return "plan_gate"
|
|
|
|
|
default:
|
|
|
|
|
return "plan_gate"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleListProcessingJobs(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
cid, ok := CompanyIDFromContext(r.Context())
|
|
|
|
|
if !ok || cid == uuid.Nil {
|
|
|
|
|
Error(w, http.StatusUnauthorized, "unauthorized")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
limit, _ := ParseLimitOffset(r)
|
|
|
|
|
items, err := s.Processing.ListJobs(r.Context(), cid, limit)
|
|
|
|
|
if err != nil {
|
|
|
|
|
Error(w, http.StatusInternalServerError, "list failed")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
JSON(w, http.StatusOK, map[string]any{"jobs": processing.FormatListJobsResponse(items), "limit": limit})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleGetProcessingJob(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
cid, ok := CompanyIDFromContext(r.Context())
|
|
|
|
|
if !ok || cid == uuid.Nil {
|
|
|
|
|
Error(w, http.StatusUnauthorized, "unauthorized")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
|
|
|
|
if err != nil {
|
|
|
|
|
Error(w, http.StatusBadRequest, "invalid id")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
job, err := s.getV1ProcessJob(r.Context(), cid, id)
|
|
|
|
|
if err != nil {
|
|
|
|
|
Error(w, http.StatusNotFound, "not found")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if processing.JobStatusIncludesProducts(job.Status) {
|
|
|
|
|
items, loadErr := s.loadV1ProcessJobItems(r.Context(), cid, id, job.ProcessingType)
|
|
|
|
|
if loadErr != nil {
|
|
|
|
|
Error(w, http.StatusInternalServerError, "load failed")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-17 21:20:45 +02:00
|
|
|
items = processing.ApplyV1SEOMetaPolicy(cid, items)
|
2026-08-09 22:47:43 +02:00
|
|
|
JSON(w, http.StatusOK, processing.FormatJobStatusResponse(job, items, true))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
JSON(w, http.StatusOK, job)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleCancelProcessingJob(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
cid, ok := CompanyIDFromContext(r.Context())
|
|
|
|
|
if !ok || cid == uuid.Nil {
|
|
|
|
|
Error(w, http.StatusUnauthorized, "unauthorized")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
|
|
|
|
if err != nil {
|
|
|
|
|
Error(w, http.StatusBadRequest, "invalid id")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
job, err := s.Processing.CancelJob(r.Context(), cid, id)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if msg, ok := processing.ClientError(err); ok {
|
|
|
|
|
Error(w, http.StatusBadRequest, msg)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
LogAndError(w, http.StatusBadRequest, "could not cancel job", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
JSON(w, http.StatusOK, job)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleRetryProcessingJob(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
cid, ok := CompanyIDFromContext(r.Context())
|
|
|
|
|
if !ok || cid == uuid.Nil {
|
|
|
|
|
Error(w, http.StatusUnauthorized, "unauthorized")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
|
|
|
|
if err != nil {
|
|
|
|
|
Error(w, http.StatusBadRequest, "invalid id")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
job, err := s.Processing.RetryJob(r.Context(), cid, id)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if writePlanGate(w, err) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if errors.Is(err, processing.ErrRateLimited) {
|
|
|
|
|
Error(w, http.StatusTooManyRequests, err.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if msg, ok := processing.ClientError(err); ok {
|
|
|
|
|
Error(w, http.StatusBadRequest, msg)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
LogAndError(w, http.StatusBadRequest, "could not retry job", err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := s.Jobs.EnqueueProcessingJob(r.Context(), job.ID); err != nil {
|
|
|
|
|
Error(w, http.StatusInternalServerError, "enqueue failed")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
JSON(w, http.StatusAccepted, job)
|
|
|
|
|
}
|