Files
nexarch/dms/internal/trigger/trigger.go
patrick d33cb617be
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) 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 / 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
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
INT-20: gofmt
2026-09-22 13:10:32 +02:00

203 lines
6.8 KiB
Go

// Package trigger implementiert INT-20: Trigger loesen bei einem Ereignis
// (Upload abgeschlossen, Texterkennung fertig, E-Rechnung geprueft) eine
// Aktion aus (Webhook, Tag setzen, Dokumententyp setzen). Ein Trigger-Fehler
// wird protokolliert, blockiert aber nie die Verarbeitung des Dokuments
// selbst (fire-and-forget mit Nachweis in trigger_runs und dem Audit-Log).
package trigger
import (
"context"
"errors"
"fmt"
"regexp"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/audittrail"
)
const (
EventUploaded = "document.uploaded"
EventOCRCompleted = "document.ocr_completed"
EventEInvoiceChecked = "document.einvoice_checked"
ActionWebhook = "webhook"
ActionSetTag = "set_tag"
ActionSetType = "set_type"
maxPatternLength = 500
maxNameLength = 200
)
var validEvents = map[string]bool{
EventUploaded: true,
EventOCRCompleted: true,
EventEInvoiceChecked: true,
}
var (
ErrNotFound = errors.New("trigger: nicht gefunden")
ErrInvalid = errors.New("trigger: ungueltig")
)
// Trigger ist eine Datenbankzeile; ActionSecret ist nur beim Anlegen/Aendern
// gesetzt (Klartext-Eingabe), nie beim Lesen (siehe Store.List/Get).
type Trigger struct {
ID string
Name string
Event string
FilenamePattern *string
DocumentTypeID *string
ActionType string
ActionTagID *string
ActionDocumentTypeID *string
WebhookURL *string
Enabled bool
CreatedBy string
}
func invalid(format string, a ...any) error {
return fmt.Errorf("%w: %s", ErrInvalid, fmt.Sprintf(format, a...))
}
func (t Trigger) validate() error {
name := strings.TrimSpace(t.Name)
if name == "" || len(name) > maxNameLength {
return invalid("name muss 1 bis %d zeichen lang sein", maxNameLength)
}
if !validEvents[t.Event] {
return invalid("unbekanntes ereignis %q", t.Event)
}
if t.FilenamePattern != nil {
if len(*t.FilenamePattern) == 0 || len(*t.FilenamePattern) > maxPatternLength {
return invalid("dateinamensmuster muss 1 bis %d zeichen lang sein", maxPatternLength)
}
if _, err := regexp.Compile(*t.FilenamePattern); err != nil {
return invalid("dateinamensmuster ist kein gueltiger regulaerer ausdruck: %v", err)
}
}
switch t.ActionType {
case ActionWebhook:
if t.WebhookURL == nil || strings.TrimSpace(*t.WebhookURL) == "" {
return invalid("webhook-aktion braucht eine ziel-url")
}
if err := ValidateWebhookURL(*t.WebhookURL); err != nil {
return invalid("webhook-url: %v", err)
}
case ActionSetTag:
if t.ActionTagID == nil || strings.TrimSpace(*t.ActionTagID) == "" {
return invalid("set_tag-aktion braucht ein tag")
}
case ActionSetType:
if t.ActionDocumentTypeID == nil || strings.TrimSpace(*t.ActionDocumentTypeID) == "" {
return invalid("set_type-aktion braucht einen dokumententyp")
}
default:
return invalid("unbekannte aktion %q", t.ActionType)
}
return nil
}
const columns = `id, name, event, filename_pattern, document_type_id, action_type, action_tag_id, action_document_type_id, webhook_url, enabled, created_by`
type scanner interface{ Scan(dest ...any) error }
func scanTrigger(row scanner) (Trigger, error) {
var t Trigger
err := row.Scan(&t.ID, &t.Name, &t.Event, &t.FilenamePattern, &t.DocumentTypeID, &t.ActionType, &t.ActionTagID, &t.ActionDocumentTypeID, &t.WebhookURL, &t.Enabled, &t.CreatedBy)
return t, err
}
func referenceError(err error) error {
if strings.Contains(err.Error(), "SQLSTATE 23503") {
return invalid("verweist auf einen unbekannten dokumententyp, tag oder ersteller")
}
return fmt.Errorf("trigger: speichern: %w", err)
}
// Create legt einen Trigger an. secret ist nur bei action_type=webhook relevant
// (optional) und wird ueber Vault.Seal verpackt, nie im Klartext gespeichert.
func Create(ctx context.Context, pool *pgxpool.Pool, v Vault, tenantSlug, actor string, t Trigger, secret string) (Trigger, error) {
t.Enabled = true
t.CreatedBy = actor
if err := t.validate(); err != nil {
return Trigger{}, err
}
var wrappedDEK, secretEnc []byte
if t.ActionType == ActionWebhook && secret != "" {
var err error
wrappedDEK, secretEnc, err = v.Seal(ctx, tenantSlug, secret)
if err != nil {
return Trigger{}, fmt.Errorf("trigger: geheimnis verschluesseln: %w", err)
}
}
out, err := scanTrigger(pool.QueryRow(ctx, `
INSERT INTO triggers (name, event, filename_pattern, document_type_id, action_type, action_tag_id, action_document_type_id, webhook_url, webhook_secret_wrapped_dek, webhook_secret_enc, enabled, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING `+columns,
t.Name, t.Event, t.FilenamePattern, t.DocumentTypeID, t.ActionType, t.ActionTagID, t.ActionDocumentTypeID, t.WebhookURL, wrappedDEK, secretEnc, t.Enabled, actor))
if err != nil {
return Trigger{}, referenceError(err)
}
if err := audittrail.Record(ctx, pool, actor, "trigger_created", "trigger", out.ID, map[string]any{"name": out.Name, "event": out.Event}); err != nil {
return Trigger{}, err
}
return out, nil
}
// SetEnabled aktiviert/deaktiviert einen Trigger, ohne die Konfiguration zu aendern.
func SetEnabled(ctx context.Context, pool *pgxpool.Pool, actor, id string, enabled bool) error {
tag, err := pool.Exec(ctx, `UPDATE triggers SET enabled = $2, updated_at = now() WHERE id = $1`, id, enabled)
if err != nil {
return fmt.Errorf("trigger: aktivieren/deaktivieren: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
action := "trigger_disabled"
if enabled {
action = "trigger_enabled"
}
return audittrail.Record(ctx, pool, actor, action, "trigger", id, nil)
}
// Delete entfernt einen Trigger (und mit ihm die trigger_runs, ON DELETE CASCADE).
func Delete(ctx context.Context, pool *pgxpool.Pool, actor, id string) error {
tag, err := pool.Exec(ctx, `DELETE FROM triggers WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("trigger: loeschen: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return audittrail.Record(ctx, pool, actor, "trigger_deleted", "trigger", id, nil)
}
// Get liefert einen Trigger ohne das Webhook-Geheimnis.
func Get(ctx context.Context, pool *pgxpool.Pool, id string) (Trigger, error) {
t, err := scanTrigger(pool.QueryRow(ctx, `SELECT `+columns+` FROM triggers WHERE id = $1`, id))
if errors.Is(err, pgx.ErrNoRows) {
return Trigger{}, ErrNotFound
}
return t, err
}
// List liefert alle Trigger, ohne das Webhook-Geheimnis.
func List(ctx context.Context, pool *pgxpool.Pool) ([]Trigger, error) {
rows, err := pool.Query(ctx, `SELECT `+columns+` FROM triggers ORDER BY name`)
if err != nil {
return nil, fmt.Errorf("trigger: liste lesen: %w", err)
}
defer rows.Close()
out := []Trigger{}
for rows.Next() {
t, err := scanTrigger(rows)
if err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}