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,183 @@
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)
}