CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Canceled after 0s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Canceled after 0s
Import-Assistent fuer paperless-ngx-Exporte (dieses Projekts primaeres Referenzsystem): Parser, Feld-Mapping-Vorschlag ueber ENG-04s fieldmapping.Store, Umwandlung in DMS2-01-Bundles, Import-Lauf mit Erfolgs-/Fehleranzahl. Mapping-Vorschau-Oberflaeche (Next.js/shl). Echte Funde dabei behoben: fieldmapping.FieldDef hatte keine JSON-Tags (serialisierte als NexarchField/Required statt nexarch_field/required, API-Antwort damit fuer das Frontend unbrauchbar); die neuen BFF-Routen lieferten ohne cache:no-store eine veraltete Antwort aus Next.js' Fetch-Cache aus. 4/4 Akzeptanzkriterien erfuellt, alle 4 Prüfungen real durchgefuehrt inkl. echtem Chrome/Puppeteer-Ende-zu-Ende-Test (Mapping im Browser geaendert, Import mit geaenderter Zuordnung erfolgreich ausgefuehrt). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
189 lines
6.0 KiB
Go
189 lines
6.0 KiB
Go
// Package dms2importapi is the HTTP binding for DMS2-02's import
|
|
// assistant: upload a third-party export, get a field-mapping preview
|
|
// back, adjust it, then execute the import and see the result. Identity
|
|
// comes from X-User-Id/X-Tenant-Slug, the same transitional pattern
|
|
// connect/internal/statusapi already uses.
|
|
package dms2importapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"gitea.perlbach24.de/scripte/nexarch/connect/internal/dms2import"
|
|
"gitea.perlbach24.de/scripte/nexarch/connect/internal/fieldmapping"
|
|
)
|
|
|
|
// connectorName/instance identify the field mapping saved for this
|
|
// assistant in fieldmapping.Store — one mapping per tenant, since a
|
|
// tenant realistically migrates from one third-party system at a time.
|
|
const (
|
|
connectorName = "dms2_import"
|
|
connectorInstanceID = "paperless_ngx"
|
|
maxUploadBytes int64 = 200 << 20 // 200 MiB: generous for a test/migration export, not unbounded
|
|
)
|
|
|
|
type Server struct {
|
|
mappings *fieldmapping.Store
|
|
runs *dms2import.Store
|
|
}
|
|
|
|
func NewServer(mappings *fieldmapping.Store, runs *dms2import.Store) *Server {
|
|
return &Server{mappings: mappings, runs: runs}
|
|
}
|
|
|
|
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
|
|
mux.HandleFunc("POST /dms2/import/preview", s.requireIdentity(s.handlePreview))
|
|
mux.HandleFunc("POST /dms2/import/execute", s.requireIdentity(s.handleExecute))
|
|
mux.HandleFunc("GET /dms2/import/runs/{id}", s.requireIdentity(s.handleGetRun))
|
|
}
|
|
|
|
func (s *Server) requireIdentity(next func(w http.ResponseWriter, r *http.Request, tenantSlug string)) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Header.Get("X-User-Id") == "" {
|
|
writeError(w, http.StatusUnauthorized, "X-User-Id fehlt")
|
|
return
|
|
}
|
|
tenant := r.Header.Get("X-Tenant-Slug")
|
|
if tenant == "" {
|
|
writeError(w, http.StatusUnauthorized, "X-Tenant-Slug fehlt")
|
|
return
|
|
}
|
|
next(w, r, tenant)
|
|
}
|
|
}
|
|
|
|
type previewResponse struct {
|
|
DocumentCount int `json:"document_count"`
|
|
AvailableFields []string `json:"available_fields"`
|
|
SuggestedMapping []fieldmapping.Entry `json:"suggested_mapping"`
|
|
Schema []fieldmapping.FieldDef `json:"schema"`
|
|
}
|
|
|
|
func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request, _ string) {
|
|
data, ok := readUploadedZip(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
docs, err := dms2import.ParsePaperlessExport(data)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
fields := dms2import.DetectFields(docs)
|
|
writeJSON(w, http.StatusOK, previewResponse{
|
|
DocumentCount: len(docs),
|
|
AvailableFields: fields,
|
|
SuggestedMapping: dms2import.SuggestMapping(fields),
|
|
Schema: dms2import.Schema(),
|
|
})
|
|
}
|
|
|
|
type executeResponse struct {
|
|
RunID string `json:"run_id"`
|
|
DocumentCount int `json:"document_count"`
|
|
SuccessCount int `json:"success_count"`
|
|
ErrorCount int `json:"error_count"`
|
|
Errors []string `json:"errors"`
|
|
}
|
|
|
|
func (s *Server) handleExecute(w http.ResponseWriter, r *http.Request, tenantSlug string) {
|
|
data, ok := readUploadedZip(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var entries []fieldmapping.Entry
|
|
if err := json.Unmarshal([]byte(r.FormValue("mapping")), &entries); err != nil {
|
|
writeError(w, http.StatusBadRequest, "mapping ist kein gueltiges JSON-Feld-Array")
|
|
return
|
|
}
|
|
|
|
schema := dms2import.Schema()
|
|
saved, err := s.mappings.Save(r.Context(), fieldmapping.Ref{
|
|
TenantSlug: tenantSlug, ConnectorName: connectorName, ConnectorInstanceID: connectorInstanceID,
|
|
}, entries, schema)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
if err := fieldmapping.ValidateForExecution(saved, schema); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
docs, err := dms2import.ParsePaperlessExport(data)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
bundles, buildErrs := dms2import.BuildBundles(docs, saved)
|
|
|
|
run, err := s.runs.RecordRun(r.Context(), tenantSlug, dms2import.FormatPaperlessNGX, len(docs), len(bundles), buildErrs, saved.Version)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "import-lauf konnte nicht gespeichert werden")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, executeResponse{
|
|
RunID: run.ID,
|
|
DocumentCount: run.DocumentCount,
|
|
SuccessCount: run.SuccessCount,
|
|
ErrorCount: run.ErrorCount,
|
|
Errors: run.Errors,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleGetRun(w http.ResponseWriter, r *http.Request, tenantSlug string) {
|
|
id := r.PathValue("id")
|
|
run, err := s.runs.GetRun(r.Context(), tenantSlug, id)
|
|
if err != nil {
|
|
if err == dms2import.ErrRunNotFound {
|
|
writeError(w, http.StatusNotFound, "import-lauf nicht gefunden")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "import-lauf konnte nicht gelesen werden")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, executeResponse{
|
|
RunID: run.ID,
|
|
DocumentCount: run.DocumentCount,
|
|
SuccessCount: run.SuccessCount,
|
|
ErrorCount: run.ErrorCount,
|
|
Errors: run.Errors,
|
|
})
|
|
}
|
|
|
|
func readUploadedZip(w http.ResponseWriter, r *http.Request) ([]byte, bool) {
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes)
|
|
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
|
writeError(w, http.StatusBadRequest, "multipart-anfrage konnte nicht gelesen werden (evtl. zu gross)")
|
|
return nil, false
|
|
}
|
|
file, header, err := r.FormFile("file")
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "feld 'file' fehlt")
|
|
return nil, false
|
|
}
|
|
defer file.Close()
|
|
if !strings.HasSuffix(strings.ToLower(header.Filename), ".zip") {
|
|
writeError(w, http.StatusBadRequest, "nur .zip-Exporte werden unterstuetzt")
|
|
return nil, false
|
|
}
|
|
data, err := io.ReadAll(file)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "datei konnte nicht gelesen werden")
|
|
return nil, false
|
|
}
|
|
return data, true
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, status int, msg string) {
|
|
writeJSON(w, status, map[string]string{"error": msg})
|
|
}
|