Files
patrickandClaude Sonnet 5 04ee1b6863
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (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 / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (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
Supply-Chain-Scan (Go) / govulncheck (push) 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 (Go) / govulncheck (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
DOK-02: akten-ordnerstruktur-migration
Neues Paket dms/internal/migrationfolder migriert archivdms' akten-Tabelle
strukturell 1:1 nach DOC-09 (folders, Materialized Path) und verknuepft
bereits migrierte Dokumente (DOK-01) mit ihrer Ziel-Akte ueber
documents.folder_id. Deterministische Ziel-IDs analog migrationdocument
machen erneute Laeufe idempotent, Zyklen in der Quellhierarchie werden
erkannt statt Endlosrekursion auszuloesen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-13 12:03:52 +00:00

185 lines
5.7 KiB
Go

// Package migrationfolder implementiert DOK-02: Uebernahme der
// Akten-/Ordnerstruktur (archivdms akten-Tabelle) in das DMS-Zielschema
// folders (DOC-09, Materialized Path, dms/migrations/tenant/0006). Rein
// strukturelle, metadatenbasierte Migration ueber Source — keine
// Binaerdaten, kein Import des trainierten ML-Klassifikator-Binaermodells
// (Produkt-DNA). Verknuepft zusaetzlich bereits migrierte Dokumente
// (DOK-01, dms/internal/migrationdocument) mit ihrer Ziel-Akte.
package migrationfolder
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
// SourceFolder ist eine Zeile aus archivdms' akten-Tabelle.
type SourceFolder struct {
SourceFolderID string
SourceParentFolderID *string
Name string
SourceUserID string
}
// SourceDocumentFolder bildet ein Quelldokument auf seine Quell-Akte ab.
type SourceDocumentFolder struct {
SourceDocumentID string
SourceFolderID string
}
// Source kapselt den lesenden SQL-Export-Zugriff auf archivdms.
type Source interface {
Folders(ctx context.Context) ([]SourceFolder, error)
DocumentFolders(ctx context.Context) ([]SourceDocumentFolder, error)
}
// UserResolver bildet eine archivdms-Benutzer-ID auf die bereits migrierte
// NEXARCH-Benutzer-ID ab, analog zu migrationdocument.UserResolver.
type UserResolver interface {
ResolveUserID(ctx context.Context, sourceUserID string) (string, error)
}
// ErrCycle meldet eine zyklische Elternreferenz in der Quellhierarchie.
var ErrCycle = errors.New("migrationfolder: zyklus in der quellhierarchie")
// ErrParentNotFound meldet eine Elternreferenz auf eine in der Quelle nicht
// vorhandene Akte.
var ErrParentNotFound = errors.New("migrationfolder: elternordner in quelle nicht gefunden")
// Migrator uebertraegt Akten/Ordner einer Quelle in genau einen
// Ziel-Tenant.
type Migrator struct {
pool *pgxpool.Pool
source Source
users UserResolver
tenantSlug string
}
func NewMigrator(pool *pgxpool.Pool, source Source, users UserResolver, tenantSlug string) *Migrator {
return &Migrator{pool: pool, source: source, users: users, tenantSlug: tenantSlug}
}
// Result fasst den Ausgang eines Migrationslaufs zusammen.
type Result struct {
FoldersMigrated int
FoldersSkipped int
DocumentsLinked int
}
type folderMigration struct {
byID map[string]SourceFolder
idCache map[string]string
pathCache map[string]string
visiting map[string]bool
}
// MigrateAll uebertraegt alle Akten/Ordner samt Hierarchie
// (Akzeptanzkriterium 1) und verknuepft anschliessend jedes migrierte
// Dokument mit seiner Ziel-Akte (Akzeptanzkriterium 2). Akten-IDs im Ziel
// werden wie bei DOK-01 deterministisch aus den Quell-IDs abgeleitet, ein
// erneuter Lauf erzeugt daher keine Duplikate.
func (m *Migrator) MigrateAll(ctx context.Context) (Result, error) {
var res Result
folders, err := m.source.Folders(ctx)
if err != nil {
return res, fmt.Errorf("migrationfolder: akten aus quelle lesen: %w", err)
}
fm := &folderMigration{
byID: make(map[string]SourceFolder, len(folders)),
idCache: map[string]string{},
pathCache: map[string]string{},
visiting: map[string]bool{},
}
for _, f := range folders {
fm.byID[f.SourceFolderID] = f
}
for _, f := range folders {
if _, _, err := m.migrateFolder(ctx, fm, f.SourceFolderID, &res); err != nil {
return res, err
}
}
docFolders, err := m.source.DocumentFolders(ctx)
if err != nil {
return res, fmt.Errorf("migrationfolder: dokument-akten-zuordnung aus quelle lesen: %w", err)
}
for _, df := range docFolders {
documentID := deriveUUID("document", m.tenantSlug, df.SourceDocumentID)
folderID := deriveUUID("folder", m.tenantSlug, df.SourceFolderID)
tag, err := m.pool.Exec(ctx, `
UPDATE documents SET folder_id = $2 WHERE id = $1 AND folder_id IS DISTINCT FROM $2
`, documentID, folderID)
if err != nil {
return res, fmt.Errorf("migrationfolder: dokument %q mit akte %q verknuepfen: %w", df.SourceDocumentID, df.SourceFolderID, err)
}
if tag.RowsAffected() > 0 {
res.DocumentsLinked++
}
}
return res, nil
}
func (m *Migrator) migrateFolder(ctx context.Context, fm *folderMigration, sourceFolderID string, res *Result) (folderID, path string, err error) {
if id, ok := fm.idCache[sourceFolderID]; ok {
return id, fm.pathCache[sourceFolderID], nil
}
if fm.visiting[sourceFolderID] {
return "", "", fmt.Errorf("%w: %q", ErrCycle, sourceFolderID)
}
fm.visiting[sourceFolderID] = true
defer delete(fm.visiting, sourceFolderID)
src, ok := fm.byID[sourceFolderID]
if !ok {
return "", "", fmt.Errorf("%w: %q", ErrParentNotFound, sourceFolderID)
}
folderID = deriveUUID("folder", m.tenantSlug, sourceFolderID)
var parentID *string
var parentPath string
if src.SourceParentFolderID != nil {
pid, ppath, err := m.migrateFolder(ctx, fm, *src.SourceParentFolderID, res)
if err != nil {
return "", "", err
}
parentID = &pid
parentPath = ppath
}
if parentPath == "" {
path = "/" + folderID + "/"
} else {
path = parentPath + folderID + "/"
}
createdBy, err := m.users.ResolveUserID(ctx, src.SourceUserID)
if err != nil {
return "", "", fmt.Errorf("migrationfolder: ersteller fuer akte %q aufloesen: %w", sourceFolderID, err)
}
tag, err := m.pool.Exec(ctx, `
INSERT INTO folders (id, parent_folder_id, name, path, created_by)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO NOTHING
`, folderID, parentID, src.Name, path, createdBy)
if err != nil {
return "", "", fmt.Errorf("migrationfolder: akte %q anlegen: %w", sourceFolderID, err)
}
if tag.RowsAffected() > 0 {
res.FoldersMigrated++
} else {
res.FoldersSkipped++
}
fm.idCache[sourceFolderID] = folderID
fm.pathCache[sourceFolderID] = path
return folderID, path, nil
}