package store import ( "database/sql" "errors" "fmt" "github.com/google/uuid" ) const triggerColumns = `id, kind, name, config, webhook_secret, webhook_signing_secret, webhook_require_signature, created_at, updated_at` func scanTrigger(s rowScanner) (Trigger, error) { var t Trigger var requireSig int if err := s.Scan(&t.ID, &t.Kind, &t.Name, &t.Config, &t.WebhookSecret, &t.WebhookSigningSecret, &requireSig, &t.CreatedAt, &t.UpdatedAt); err != nil { return Trigger{}, err } t.WebhookRequireSignature = requireSig != 0 return t, nil } // CreateTrigger inserts a new trigger row. Kind + Name are required. // Config is normalized to "{}" when empty; webhook secret is left empty // unless the caller pre-populates it. func (s *Store) CreateTrigger(t Trigger) (Trigger, error) { if t.ID == "" { t.ID = uuid.New().String() } if t.Config == "" { t.Config = "{}" } t.CreatedAt = Now() t.UpdatedAt = t.CreatedAt _, err := s.db.Exec( `INSERT INTO triggers (`+triggerColumns+`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, t.ID, t.Kind, t.Name, t.Config, t.WebhookSecret, t.WebhookSigningSecret, BoolToInt(t.WebhookRequireSignature), t.CreatedAt, t.UpdatedAt, ) if err != nil { return Trigger{}, fmt.Errorf("insert trigger: %w", translateSQLError(err)) } return t, nil } // GetTriggerByID returns one trigger. func (s *Store) GetTriggerByID(id string) (Trigger, error) { t, err := scanTrigger(s.db.QueryRow( `SELECT `+triggerColumns+` FROM triggers WHERE id = ?`, id, )) if errors.Is(err, sql.ErrNoRows) { return Trigger{}, fmt.Errorf("trigger %s: %w", id, ErrNotFound) } if err != nil { return Trigger{}, fmt.Errorf("query trigger: %w", err) } return t, nil } // GetTriggerByWebhookSecret resolves a trigger by its inbound webhook // secret. Empty input is treated as not-found to avoid accidental matches // against rows whose webhook is disabled. func (s *Store) GetTriggerByWebhookSecret(secret string) (Trigger, error) { if secret == "" { return Trigger{}, fmt.Errorf("empty secret: %w", ErrNotFound) } t, err := scanTrigger(s.db.QueryRow( `SELECT `+triggerColumns+` FROM triggers WHERE webhook_secret = ?`, secret, )) if errors.Is(err, sql.ErrNoRows) { return Trigger{}, ErrNotFound } if err != nil { return Trigger{}, fmt.Errorf("query trigger by webhook secret: %w", err) } return t, nil } // GetTriggerByName resolves a trigger by its unique human name. func (s *Store) GetTriggerByName(name string) (Trigger, error) { if name == "" { return Trigger{}, fmt.Errorf("empty name: %w", ErrNotFound) } t, err := scanTrigger(s.db.QueryRow( `SELECT `+triggerColumns+` FROM triggers WHERE name = ?`, name, )) if errors.Is(err, sql.ErrNoRows) { return Trigger{}, ErrNotFound } if err != nil { return Trigger{}, fmt.Errorf("query trigger by name: %w", err) } return t, nil } // ListTriggers returns all triggers, ordered by name. Optional kind // filter — empty string returns everything. func (s *Store) ListTriggers(kind string) ([]Trigger, error) { var rows *sql.Rows var err error if kind == "" { rows, err = s.db.Query(`SELECT ` + triggerColumns + ` FROM triggers ORDER BY name`) } else { rows, err = s.db.Query(`SELECT `+triggerColumns+` FROM triggers WHERE kind = ? ORDER BY name`, kind) } if err != nil { return nil, fmt.Errorf("query triggers: %w", err) } defer rows.Close() out := []Trigger{} for rows.Next() { t, err := scanTrigger(rows) if err != nil { return nil, fmt.Errorf("scan trigger: %w", err) } out = append(out, t) } return out, rows.Err() } // TriggerWithBindingCount projects a Trigger plus its current binding // count in a single round-trip. Used by /api/triggers list rendering so // the response avoids one COUNT(*) per trigger row. type TriggerWithBindingCount struct { Trigger BindingCount int } // ListTriggersWithBindingCount returns every trigger ordered by name // with the count of its bindings joined in. Optional kind filter. func (s *Store) ListTriggersWithBindingCount(kind string) ([]TriggerWithBindingCount, error) { const base = ` SELECT t.id, t.kind, t.name, t.config, t.webhook_secret, t.webhook_signing_secret, t.webhook_require_signature, t.created_at, t.updated_at, COALESCE(b.cnt, 0) FROM triggers t LEFT JOIN ( SELECT trigger_id, COUNT(*) AS cnt FROM workload_trigger_bindings GROUP BY trigger_id ) b ON b.trigger_id = t.id` var rows *sql.Rows var err error if kind == "" { rows, err = s.db.Query(base + ` ORDER BY t.name`) } else { rows, err = s.db.Query(base+` WHERE t.kind = ? ORDER BY t.name`, kind) } if err != nil { return nil, fmt.Errorf("query triggers with binding count: %w", err) } defer rows.Close() out := []TriggerWithBindingCount{} for rows.Next() { var t Trigger var requireSig int var count int if err := rows.Scan(&t.ID, &t.Kind, &t.Name, &t.Config, &t.WebhookSecret, &t.WebhookSigningSecret, &requireSig, &t.CreatedAt, &t.UpdatedAt, &count); err != nil { return nil, fmt.Errorf("scan trigger+count: %w", err) } t.WebhookRequireSignature = requireSig != 0 out = append(out, TriggerWithBindingCount{Trigger: t, BindingCount: count}) } return out, rows.Err() } // UpdateTrigger updates the mutable fields of a trigger. Kind is // immutable post-create — changing kinds would invalidate every // binding's interpretation of binding_config. func (s *Store) UpdateTrigger(t Trigger) error { t.UpdatedAt = Now() if t.Config == "" { t.Config = "{}" } result, err := s.db.Exec( `UPDATE triggers SET name=?, config=?, webhook_secret=?, webhook_signing_secret=?, webhook_require_signature=?, updated_at=? WHERE id=?`, t.Name, t.Config, t.WebhookSecret, t.WebhookSigningSecret, BoolToInt(t.WebhookRequireSignature), t.UpdatedAt, t.ID, ) if err != nil { return fmt.Errorf("update trigger: %w", translateSQLError(err)) } n, _ := result.RowsAffected() if n == 0 { return fmt.Errorf("trigger %s: %w", t.ID, ErrNotFound) } return nil } // DeleteTrigger removes a trigger row. Bindings cascade away via FK. func (s *Store) DeleteTrigger(id string) error { result, err := s.db.Exec(`DELETE FROM triggers WHERE id = ?`, id) if err != nil { return fmt.Errorf("delete trigger: %w", err) } n, _ := result.RowsAffected() if n == 0 { return fmt.Errorf("trigger %s: %w", id, ErrNotFound) } return nil } // CreateTriggerWithBindingTx atomically creates a trigger row and // binds it to a workload. Used by the workload-side inline-create-and- // bind endpoint so a binding-insert failure does not leave an orphan // trigger row behind. Returns the persisted trigger and binding. func (s *Store) CreateTriggerWithBindingTx(t Trigger, b WorkloadTriggerBinding) (Trigger, WorkloadTriggerBinding, error) { tx, err := s.db.Begin() if err != nil { return Trigger{}, WorkloadTriggerBinding{}, fmt.Errorf("begin: %w", err) } defer func() { _ = tx.Rollback() }() if t.ID == "" { t.ID = uuid.New().String() } if t.Config == "" { t.Config = "{}" } t.CreatedAt = Now() t.UpdatedAt = t.CreatedAt if _, err := tx.Exec( `INSERT INTO triggers (`+triggerColumns+`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, t.ID, t.Kind, t.Name, t.Config, t.WebhookSecret, t.WebhookSigningSecret, BoolToInt(t.WebhookRequireSignature), t.CreatedAt, t.UpdatedAt, ); err != nil { return Trigger{}, WorkloadTriggerBinding{}, fmt.Errorf("insert trigger: %w", translateSQLError(err)) } if b.ID == "" { b.ID = uuid.New().String() } if b.BindingConfig == "" { b.BindingConfig = "{}" } b.TriggerID = t.ID b.CreatedAt = t.CreatedAt b.UpdatedAt = t.UpdatedAt if _, err := tx.Exec( `INSERT INTO workload_trigger_bindings (`+bindingColumns+`) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, b.ID, b.WorkloadID, b.TriggerID, b.BindingConfig, BoolToInt(b.Enabled), b.SortOrder, b.CreatedAt, b.UpdatedAt, ); err != nil { return Trigger{}, WorkloadTriggerBinding{}, fmt.Errorf("insert binding: %w", translateSQLError(err)) } if err := tx.Commit(); err != nil { return Trigger{}, WorkloadTriggerBinding{}, fmt.Errorf("commit: %w", err) } return t, b, nil } // SetTriggerWebhookSecret rotates the inbound webhook URL secret. Pass // empty string to disable webhook ingress for this trigger. func (s *Store) SetTriggerWebhookSecret(id, secret string) error { result, err := s.db.Exec( `UPDATE triggers SET webhook_secret=?, updated_at=? WHERE id=?`, secret, Now(), id, ) if err != nil { return fmt.Errorf("update trigger webhook_secret: %w", err) } n, _ := result.RowsAffected() if n == 0 { return fmt.Errorf("trigger %s: %w", id, ErrNotFound) } return nil } // EnsureTriggerWebhookSecret returns the current secret, generating one // lazily for triggers that have none. Mirrors the workload helper. func (s *Store) EnsureTriggerWebhookSecret(id string) (string, error) { t, err := s.GetTriggerByID(id) if err != nil { return "", err } if t.WebhookSecret != "" { return t.WebhookSecret, nil } secret := generateWebhookSecret() if err := s.SetTriggerWebhookSecret(id, secret); err != nil { return "", err } return secret, nil }