// Package retentiondestroy implementiert DOC-16: DMS als Konsument des // Archive-RET-05-Vertrags. Registriert den Objekttyp "dms_document" bei // Archive (RET-09, real laufender Dienst) und empfängt/verarbeitet den // Vernichtungs-Rückruf. Kennt KEINE eigene Fristenberechnung — reiner // Adapter zwischen DMS und dem bereits feststehenden RET-05-Vertrag. package retentiondestroy import ( "context" "encoding/json" "fmt" "net/http" "time" "github.com/jackc/pgx/v5/pgxpool" "gitea.perlbach24.de/scripte/nexarch/dms/internal/jobqueue" "gitea.perlbach24.de/scripte/nexarch/dms/internal/retentionclient" "gitea.perlbach24.de/scripte/nexarch/dms/internal/storage" ) // ModuleName ist der bei Archive RET-05 registrierte Modulname. const ModuleName = "dms" // ObjectTypeDocument ist der einzige aktuell in DOC-01/FDN-02 modellierte // Objekttyp — DMS führt (noch) keinen separaten "Anhang"-Entitätstyp, // daher wird ausschließlich "dms_document" registriert (kleinste Lösung, // kein Umbau von DOC-01/FDN-02). const ObjectTypeDocument = "dms_document" // JobTypeRegister ist der FDN-04-Jobtyp für Registrierungs-Retries. const JobTypeRegister = "doc16_retention_register" // RegisterConfig ist der Registrierungs-Auftrag, sowohl für den // Erstversuch als auch als Requeue-Payload. type RegisterConfig struct { ObjectType string `json:"object_type"` RetentionClass string `json:"retention_class"` CallbackURL string `json:"callback_url"` } // RegisterOrRequeue versucht die Registrierung real gegen Archive RET-05 // (RET-09). Schlägt sie fehl (Archive nicht erreichbar/Nicht-2xx), wird // EIN Requeue-Job über FDN-04 eingereiht, statt abzustürzen oder den // Fehler zu verschlucken (Akzeptanzkriterium 3) — der ursprüngliche // Fehler wird IMMER zurückgegeben, damit der Aufrufer ihn protokolliert. func RegisterOrRequeue(ctx context.Context, client *retentionclient.Client, queue *jobqueue.Queue, cfg RegisterConfig) (queued bool, err error) { _, regErr := client.Register(ctx, ModuleName, cfg.ObjectType, cfg.RetentionClass, cfg.CallbackURL) if regErr == nil { return false, nil } if _, qerr := queue.Enqueue(ctx, JobTypeRegister, cfg, jobqueue.EnqueueOptions{ IdempotencyKey: "doc16-register-" + cfg.ObjectType, }); qerr != nil { return false, fmt.Errorf("retentiondestroy: registrierung fehlgeschlagen (%v) UND requeue fehlgeschlagen: %w", regErr, qerr) } return true, regErr } // ProcessRegisterJob verarbeitet EINEN dequeuten Registrierungs-Retry — // vom Worker (FDN-04-Konsument) aufgerufen. func ProcessRegisterJob(ctx context.Context, client *retentionclient.Client, payload json.RawMessage) error { var cfg RegisterConfig if err := json.Unmarshal(payload, &cfg); err != nil { return fmt.Errorf("retentiondestroy: job-payload lesen: %w", err) } _, err := client.Register(ctx, ModuleName, cfg.ObjectType, cfg.RetentionClass, cfg.CallbackURL) return err } type destroyRequest struct { ObjectType string `json:"object_type"` ObjectReference string `json:"object_reference"` DestroyedAt string `json:"destroyed_at"` } // DestroyCallbackHandler ist der RET-05-Rückruf-Empfänger // (Akzeptanzkriterium 2): löscht physisch alle Datei-Revisionen des // referenzierten Dokuments über storage.Service (meldet die // Größenänderung an Core, Service.Delete-Vertrag) und markiert das // Dokument als vernichtet (deleted_at). Antwortet erst NACH // erfolgreicher Löschung mit 2xx. func DestroyCallbackHandler(pool *pgxpool.Pool, storageSvc *storage.Service) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req destroyRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "ungültiger request-body: "+err.Error(), http.StatusBadRequest) return } if req.ObjectType != ObjectTypeDocument || req.ObjectReference == "" { http.Error(w, "unbekannter object_type oder fehlende object_reference", http.StatusBadRequest) return } destroyedAt, err := time.Parse(time.RFC3339, req.DestroyedAt) if err != nil { http.Error(w, "ungültiges destroyed_at: "+err.Error(), http.StatusBadRequest) return } ctx := r.Context() rows, err := pool.Query(ctx, `SELECT storage_key, size_bytes FROM file_revisions WHERE document_id = $1`, req.ObjectReference) if err != nil { http.Error(w, "revisionen lesen: "+err.Error(), http.StatusInternalServerError) return } type revision struct { key string size int64 } var revisions []revision for rows.Next() { var rv revision if err := rows.Scan(&rv.key, &rv.size); err != nil { rows.Close() http.Error(w, "revisions-zeile lesen: "+err.Error(), http.StatusInternalServerError) return } revisions = append(revisions, rv) } rowsErr := rows.Err() rows.Close() if rowsErr != nil { http.Error(w, "revisionen lesen: "+rowsErr.Error(), http.StatusInternalServerError) return } for _, rv := range revisions { if err := storageSvc.Delete(ctx, rv.key, rv.size); err != nil { http.Error(w, "objekt-storage löschen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return } } tag, err := pool.Exec(ctx, `UPDATE documents SET deleted_at = $2 WHERE id = $1`, req.ObjectReference, destroyedAt) if err != nil { http.Error(w, "dokument als vernichtet markieren: "+err.Error(), http.StatusInternalServerError) return } if tag.RowsAffected() == 0 { // Dokument existiert nicht (mehr) — aus Sicht des Rückrufs // bereits erledigt, kein Fehler (verhindert Retry-Schleifen // bei Archive fuer laengst geloeschte Objekte). w.WriteHeader(http.StatusOK) return } w.WriteHeader(http.StatusOK) } }