// Package moduleadapter implementiert RET-05: die Schnittstelle, über // die DMS und Mail ihre Objekttypen bei Archive registrieren, statt // eigene Retention-Logik zu bauen. BEWUSST NUR DAS INTERFACE UND // ARCHIVES EIGENE SEITE (Registrierungs-API + Rückruf-Auslöser) — die // eigentlichen Rückruf-EMPFÄNGER (DMS'/Mails Löschbestätigungs-Endpunkte) // sind NICHT Teil dieses Tickets, damit spätere DMS-/Mail-Kacheln // gegen ein bereits feststehendes, nicht nachträglich verändertes // Interface bauen (Nutzervorgabe). package moduleadapter import ( "bytes" "context" "encoding/json" "fmt" "net/http" "time" "github.com/jackc/pgx/v5/pgxpool" ) // Registration ist EIN registrierter Objekttyp eines Moduls. type Registration struct { ID string ModuleName string ObjectType string RetentionClass string CallbackURL string } // Register registriert einen Objekttyp eines Moduls mit Rückruf-Adresse // für Löschbestätigung — mandantenfähig durch Modell C (physisch // isolierte Tenant-DB, kein tenant_id-Feld nötig). Idempotent im Sinne // von Akzeptanzkriterium 3: eine erneute Registrierung DESSELBEN // Objekttyps ändert NICHTS am bestehenden Zustand (kein Überschreiben // der callback_url) — anders als RET-01s RegisterObject (dort // UPSERT-artig), weil ein bereits registrierter Rückruf nicht // stillschweigend durch eine zweite, möglicherweise abweichende // Registrierung ersetzt werden darf. func Register(ctx context.Context, pool *pgxpool.Pool, moduleName, objectType, retentionClass, callbackURL string) (Registration, error) { var reg Registration err := pool.QueryRow(ctx, ` INSERT INTO module_registrations (module_name, object_type, retention_class, callback_url) VALUES ($1, $2, $3, $4) ON CONFLICT (module_name, object_type) DO NOTHING RETURNING id, module_name, object_type, retention_class, callback_url `, moduleName, objectType, retentionClass, callbackURL).Scan(®.ID, ®.ModuleName, ®.ObjectType, ®.RetentionClass, ®.CallbackURL) if err == nil { return reg, nil } // ON CONFLICT DO NOTHING liefert keine Zeile zurueck (pgx: ErrNoRows) - // bestehende Registrierung unveraendert nachlesen und zurueckgeben. err = pool.QueryRow(ctx, ` SELECT id, module_name, object_type, retention_class, callback_url FROM module_registrations WHERE module_name = $1 AND object_type = $2 `, moduleName, objectType).Scan(®.ID, ®.ModuleName, ®.ObjectType, ®.RetentionClass, ®.CallbackURL) if err != nil { return Registration{}, fmt.Errorf("moduleadapter: registrierung lesen/anlegen: %w", err) } return reg, nil } // ListRegistrations liefert alle registrierten Objekttypen — Grundlage // für Statusübersichten und Tests (Pflichtprüfung 1: zwei Module // parallel registriert ohne Kollision). func ListRegistrations(ctx context.Context, pool *pgxpool.Pool) ([]Registration, error) { rows, err := pool.Query(ctx, `SELECT id, module_name, object_type, retention_class, callback_url FROM module_registrations ORDER BY module_name, object_type`) if err != nil { return nil, fmt.Errorf("moduleadapter: registrierungen auflisten: %w", err) } defer rows.Close() var regs []Registration for rows.Next() { var r Registration if err := rows.Scan(&r.ID, &r.ModuleName, &r.ObjectType, &r.RetentionClass, &r.CallbackURL); err != nil { return nil, fmt.Errorf("moduleadapter: registrierungs-zeile lesen: %w", err) } regs = append(regs, r) } return regs, rows.Err() } // DestructionNotice ist der Rückruf-Payload bei Vernichtung eines // Objekts (Akzeptanzkriterium 2) — das feststehende Vertragsformat, das // jeder Modul-Rückruf-Empfänger erwarten muss. type DestructionNotice struct { ObjectType string `json:"object_type"` ObjectReference string `json:"object_reference"` DestroyedAt time.Time `json:"destroyed_at"` } // NotifyDestruction ruft das registrierte Modul beim Vernichten eines // Objekts zurück, statt dass Archive selbst Modul-Interna kennen müsste // (Akzeptanzkriterium 2). Fehlerverhalten: liefert den Fehler an den // Aufrufer zurück, statt ihn zu verschlucken — ein fehlgeschlagener // Rückruf ist ein Fehlerzustand, der behandelt/wiederholt werden muss // (Wiederholungslogik ist NICHT Teil dieses Tickets, nur der // Interface-Vertrag: Erfolg = HTTP 2xx, sonst Fehler). func NotifyDestruction(ctx context.Context, client *http.Client, callbackURL string, notice DestructionNotice) error { body, err := json.Marshal(notice) if err != nil { return fmt.Errorf("moduleadapter: rückruf-payload kodieren: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, callbackURL, bytes.NewReader(body)) if err != nil { return fmt.Errorf("moduleadapter: rückruf-request erstellen: %w", err) } req.Header.Set("Content-Type", "application/json") resp, err := client.Do(req) if err != nil { return fmt.Errorf("moduleadapter: rückruf fehlgeschlagen: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("moduleadapter: rückruf-endpunkt antwortete mit status %d", resp.StatusCode) } return nil }