package api import ( "errors" "net/http" "strconv" "archivdms/internal/audit" "archivdms/internal/storage" ) // processingJobResponse ist die schlanke Sicht auf einen Queue-Job, die das // Frontend für Statusbadge und Retry-Button braucht. Bewusst nicht der volle // storage.ProcessingJob: interne Felder (derive_title, next_attempt_at, // tenant_id) haben in der UI nichts zu suchen. type processingJobResponse struct { DocumentID int64 `json:"document_id"` Status string `json:"status"` RetryCount int `json:"retry_count"` ErrorMessage string `json:"error_message,omitempty"` } // handleGetProcessingJob liefert den Verarbeitungsstatus eines Dokuments // (GET /api/documents/{id}/processing-job). // // ACL wie bei allen Dokument-Sub-Routen: erst GetDocument(id, tenantID) — der // tenant_id-Filter dort ist der IDOR-Guard, ein fremdes Dokument liefert 404 // noch bevor irgendein Job gelesen wird. // // Hat ein Dokument keinen Job (kompletter Altbestand vor Einführung der // Queue), wird KEIN 404 geliefert, sondern der processing_status des // Dokuments selbst (Spalten-Default 'done'). Damit muss das Frontend keinen // Sonderfall kennen: es bekommt immer einen Status. func (s *Server) handleGetProcessingJob(w http.ResponseWriter, r *http.Request) { sess := sessionFromCtx(r.Context()) id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil || sess.TenantID == nil { writeError(w, http.StatusBadRequest, "invalid document id") return } tenantID := *sess.TenantID doc, err := s.store.GetDocument(r.Context(), id, tenantID) if err != nil { if errors.Is(err, storage.ErrDocumentNotFound) { writeError(w, http.StatusNotFound, "document not found") return } writeError(w, http.StatusInternalServerError, "load document failed") return } job, err := s.store.GetJobForDocument(r.Context(), id, tenantID) if err != nil { if errors.Is(err, storage.ErrNoJob) { status := doc.ProcessingStatus if status == "" { status = storage.JobStatusDone } writeJSON(w, http.StatusOK, processingJobResponse{DocumentID: id, Status: status}) return } writeError(w, http.StatusInternalServerError, "load processing job failed") return } writeJSON(w, http.StatusOK, processingJobResponse{ DocumentID: id, Status: job.Status, RetryCount: job.RetryCount, ErrorMessage: job.ErrorMessage, }) } // handleRetryProcessingJob stellt einen dauerhaft fehlgeschlagenen Job manuell // zurück in die Queue (POST /api/documents/{id}/processing-job/retry). // // Nur aus dem Status 'failed' heraus erlaubt — ein laufender oder bereits // fertiger Job darf nicht zurückgesetzt werden (409), sonst könnte ein Klick // eine gerade laufende Verarbeitung doppelt anstoßen. Der eigentliche Retry // läuft danach ganz normal über den Dispatcher. func (s *Server) handleRetryProcessingJob(w http.ResponseWriter, r *http.Request) { sess := sessionFromCtx(r.Context()) id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil || sess.TenantID == nil { writeError(w, http.StatusBadRequest, "invalid document id") return } tenantID := *sess.TenantID if _, err := s.store.GetDocument(r.Context(), id, tenantID); err != nil { if errors.Is(err, storage.ErrDocumentNotFound) { writeError(w, http.StatusNotFound, "document not found") return } writeError(w, http.StatusInternalServerError, "load document failed") return } job, err := s.store.GetJobForDocument(r.Context(), id, tenantID) if err != nil { if errors.Is(err, storage.ErrNoJob) { writeError(w, http.StatusNotFound, "no processing job for document") return } writeError(w, http.StatusInternalServerError, "load processing job failed") return } if job.Status != storage.JobStatusFailed { writeError(w, http.StatusConflict, "processing job is not in failed state") return } if err := s.store.RequeueJob(r.Context(), job.ID, tenantID); err != nil { s.audlog.Log(audit.Entry{EventType: audit.EventDocumentProcessed, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: "manual retry failed: " + err.Error()}) writeError(w, http.StatusInternalServerError, "requeue failed") return } s.audlog.Log(audit.Entry{EventType: audit.EventDocumentProcessed, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: true, Detail: "manual retry requeued job_id=" + strconv.FormatInt(job.ID, 10)}) writeJSON(w, http.StatusOK, processingJobResponse{DocumentID: id, Status: storage.JobStatusQueued}) }