package dedup import ( "context" "errors" "fmt" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) // Store verwaltet bekannte Inhalts-Hashes je Mandant. tenant_slug ist // fester Bestandteil des Primärschlüssels (Akzeptanzkriterium 3: // mandantenübergreifend korrekt getrennt) — auch wenn Store einen mit // anderen Mandanten geteilten Pool erhält, kann ein Hash-Treffer nie // über Mandantengrenzen hinweg entstehen. type Store struct { pool *pgxpool.Pool tenantSlug string } func NewStore(pool *pgxpool.Pool, tenantSlug string) *Store { return &Store{pool: pool, tenantSlug: tenantSlug} } // EnsureSchema legt die Tabelle an, falls sie noch nicht existiert — // gleiches Muster wie mail/internal/example (kein zentraler // Migrationsläufer für Mandanten-Datenbanken im Mail-Modul vorhanden). func (s *Store) EnsureSchema(ctx context.Context) error { if _, err := s.pool.Exec(ctx, ` CREATE TABLE IF NOT EXISTS mail_content_hashes ( tenant_slug TEXT NOT NULL, content_hash TEXT NOT NULL, object_key TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (tenant_slug, content_hash) ) `); err != nil { return fmt.Errorf("dedup: schema anlegen: %w", err) } return nil } // Register trägt contentHash für den Mandanten als neu bekannt ein // (Akzeptanzkriterium 1) und referenziert das Original, statt es // redundant zu speichern (Akzeptanzkriterium 2): existiert derselbe // Hash für DIESEN Mandanten bereits mit einem ANDEREN object_key, // liefert Register isDuplicate=true und den object_key des Originals — // der Aufrufer legt den neuen Inhalt dann NICHT ab. func (s *Store) Register(ctx context.Context, contentHash, objectKey string) (isDuplicate bool, existingKey string, err error) { if _, err := s.pool.Exec(ctx, ` INSERT INTO mail_content_hashes (tenant_slug, content_hash, object_key) VALUES ($1, $2, $3) ON CONFLICT (tenant_slug, content_hash) DO NOTHING `, s.tenantSlug, contentHash, objectKey); err != nil { return false, "", fmt.Errorf("dedup: hash eintragen: %w", err) } var storedKey string err = s.pool.QueryRow(ctx, ` SELECT object_key FROM mail_content_hashes WHERE tenant_slug = $1 AND content_hash = $2 `, s.tenantSlug, contentHash).Scan(&storedKey) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return false, "", fmt.Errorf("dedup: gerade eingetragenen hash nicht wiedergefunden") } return false, "", fmt.Errorf("dedup: eintrag lesen: %w", err) } if storedKey != objectKey { return true, storedKey, nil } return false, "", nil }