FDN-01: repository & projektgerüst

Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
This commit is contained in:
2026-08-11 21:27:53 +02:00
parent 40ed80da71
commit 9a24ea29e1
274 changed files with 53708 additions and 0 deletions
+122
View File
@@ -0,0 +1,122 @@
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})
}