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 } 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) }