# Trigger-Based Automation Design

> **Status:** Draft
> **Author:** OpenClaw
> **Date:** 2026-04-16
> **Related:** Quote-to-Order E2E Flow (`SCN-QTO-001`), FlowExecutor, FlowTriggerListener

---

## 1. Background

Salesforce provides **Triggers** — Apex code blocks that run automatically before or after DML operations on records. They are the core mechanism for enforcing business rules like:

- Auto-populating fields when a record is saved
- Validating data before insert/update
- Creating or updating related records after approval
- Enforcing cross-object constraints (e.g., an Order requires an effective Contract)

NexusCRM's backend is written in Go, but the business logic embedded in these Triggers must be preserved during migration.

This document describes the **Trigger Automation Architecture** for NexusCRM: how existing Salesforce Trigger logic is represented, executed, and extended using the existing `FlowTriggerListener` + `FlowExecutor` infrastructure.

---

## 2. Existing Infrastructure

The backend already has a complete event-driven automation system:

```
PersistenceService
├── publishRecordEvent()  →  synchronous Before/After events
└── outbox.EnqueueEvent() →  asynchronous event queue
         ↓
EventBus (ports.EventPublisher)
├── RecordBeforeCreate / RecordBeforeUpdate / RecordBeforeDelete
├── RecordCreated / RecordUpdated / RecordDeleted
└── Subscribe() / Publish() / PublishAsync()
         ↓
FlowTriggerListener
├── Subscribes to all record events
├── Matches events against FlowDefinition.TriggerType + TriggerObjectOrEvent
└── Delegates to FlowExecutor
         ↓
FlowExecutor
└── ExecuteFlow() → executes Flow Actions (CreateRecord, UpdateRecord, etc.)
```

### 2.1 Key Components

| Component | File | Role |
|-----------|------|------|
| `PersistenceService` | `internal/features/records/persistence_service.go` | Publishes `RecordBefore*` and `RecordCreated/Updated/Deleted` events |
| `EventBus` | `internal/features/events/event_bus.go` | In-process pub/sub with async support |
| `FlowTriggerListener` | `internal/features/automation/flow_trigger_listener.go` | Routes record events to matching flows |
| `FlowExecutor` | `internal/features/automation/flow_executor.go` | Executes flow actions (CreateRecord, UpdateRecord, etc.) |
| `FlowDefinition` | `shared/pkg/models/salesforce_flow.go` | Flow metadata: `TriggerType`, `TriggerObjectOrEvent`, `ActiveVersionId` |
| `ApprovalService` | `internal/features/automation/approval_service.go` | Handles Quote approval lifecycle |

### 2.2 Event Types

```go
const (
    RecordBeforeCreate EventType = "record.beforeCreate"
    RecordCreated      EventType = "record.created"
    RecordBeforeUpdate EventType = "record.beforeUpdate"
    RecordUpdated      EventType = "record.updated"
    RecordBeforeDelete EventType = "record.beforeDelete"
    RecordDeleted      EventType = "record.deleted"
)
```

### 2.3 Existing FlowDefinition Records (Production)

```sql
SELECT DeveloperName, TriggerType, TriggerObjectOrEvent FROM FlowDefinition;
```

| DeveloperName | TriggerType | TriggerObjectOrEvent |
|---|---|---|
| Quote_update_nonstandard_conditions_checkbox | RecordBeforeSave | Quote |
| Quote_update_nonstandard_conditions_checkbox_2 | RecordAfterSave | Quote |
| Whether_include_man_day_or_Premium_Subscription | RecordAfterSave | Quote |
| contractOpportunityInset | RecordAfterSave | Order |
| OpportunityShareToPersalesOwner | RecordAfterSave | Opportunity |
| LeadSendemail | RecordAfterSave | Lead |
| Close_Won_Create_Renew_Opportunity | RecordAfterSave | Opportunity |
| ... | ... | ... |

**Gap:** There is no `Quote → Order` creation flow defined. The Quote-to-Order business logic is currently unimplemented.

---

## 3. Trigger Model

We model Triggers as **Flows with `RecordBeforeSave` or `RecordAfterSave` trigger types**, stored in the database and executed by `FlowTriggerListener`.

### 3.1 Trigger vs Flow Distinction

| Concept | Salesforce | NexusCRM |
|---------|-----------|----------|
| Before-save logic (auto-fill, validate) | Apex Trigger | `FlowDefinition.TriggerType = RecordBeforeSave` |
| After-save logic (create related, notify) | Apex Trigger | `FlowDefinition.TriggerType = RecordAfterSave` |
| Async long-running logic | `@future`, Queueable | Platform Event Flows |
| Declarative automation | Flow (Screen, Scheduled) | `FlowDefinition.TriggerType = Scheduled` |

### 3.2 Trigger Execution Phases

```
User Request
    ↓
BeforeSave Triggers (synchronous)
  Can modify the record → errors block the DML
  Can write to the database (side effects allowed but discouraged)
    ↓
DML Operation (Insert / Update / Delete)
    ↓
AfterSave Triggers (synchronous by default)
  Cannot modify the triggering record
  Can create/update other records (side effects)
    ↓
Response returned to user
```

### 3.3 BeforeSave vs AfterSave in FlowTriggerListener

```go
// From flow_trigger_listener.go
func (l *FlowTriggerListener) handleRecordEvent(eventType sharedEvents.EventType) ports.EventHandler {
    return func(ctx context.Context, payload interface{}) error {
        // Before-save errors BLOCK the DML operation
        if l.isBeforeSaveEvent(eventType) {
            err := l.executeRecordTriggerFlows(ctx, eventType, recordPayload)
            if err != nil {
                return err  // blocks the operation
            }
        }
        // After-save errors are logged but do not block
        l.executeRecordTriggerFlows(ctx, eventType, recordPayload)
        return nil
    }
}
```

---

## 4. Required Trigger Implementations

### 4.1 Trigger Inventory

| # | Name | Object | Event | Action | Priority |
|---|------|--------|-------|--------|----------|
| 1 | `Quote_Set_Default_Status` | Quote | RecordBeforeSave | Auto-fill `Status = "Draft"` if null | P0 |
| 2 | `Quote_Lock_Name_Number` | Quote | RecordBeforeSave | Prevent edit of auto-generated `Name`/`QuoteNumber` | P0 |
| 3 | `Quote_Calculate_NetFees` | Quote | RecordBeforeSave | Auto-calculate `NetFees`, `ACV`, `TCV` from LineItems | P1 |
| 4 | `Quote_Create_Order_On_Approval` | Quote | RecordAfterSave | When `Status == "Approved"`, create Order with Quote→Order field mapping | P0 |
| 5 | `Order_Check_Contract_BeforeCreate` | Order | RecordBeforeCreate | Reject Order creation if no effective `Contract` on Account | P0 |
| 6 | `Order_Validate_Status_Transition` | Order | RecordBeforeUpdate | Validate that `Status` transitions are legal (Draft→Activated only) | P1 |
| 7 | `Order_Activate_On_UploadPDF` | Order | RecordAfterSave | When `ArchivedOFPdfUrl` is set, auto-set `Status = "Activated"` | P1 |

### 4.2 Detailed Trigger Designs

#### Trigger 1: Quote_Set_Default_Status

**Object:** Quote | **Event:** `RecordBeforeSave` | **Priority:** P0

**Trigger Logic:**
```
IF Quote.Status == null OR Quote.Status == "" THEN
    Quote.Status = "Draft"
END IF
```

**Flow Metadata:**
```json
{
  "rules": [
    {
      "name": "SetDefaultStatus",
      "condition": "$Record.Status == null || $Record.Status == ''",
      "action": {
        "actionType": "UpdateRecord",
        "object": "Quote",
        "fields": {
          "Status": "Draft"
        }
      }
    }
  ]
}
```

#### Trigger 2: Quote_Lock_Name_Number

**Object:** Quote | **Event:** `RecordBeforeSave` | **Priority:** P0

**Trigger Logic:**
```
IF Quote.Id != null AND Quote.Name != QUOTE_ORIGINAL_NAME THEN
    THROW "Quote Name cannot be modified after creation"
END IF
```

**Implementation Note:** This is a **validation** trigger, not a field-update trigger. It should return an error if the user attempts to modify the locked `Name` or `QuoteNumber` fields.

#### Trigger 4: Quote_Create_Order_On_Approval

**Object:** Quote | **Event:** `RecordAfterSave` | **Priority:** P0

**Trigger Logic:**
```
IF Quote.Status PREVIOUSLY == "Approved" AND Quote.Status NOW == "Approved" THEN
    -- This is the transition we care about (approval just happened)
    -- Actually: we need to detect the transition from !=Approved to ==Approved
END IF

IF Quote.Status CHANGED TO "Approved" THEN
    IF Account.Contract.Status == "Effective" THEN
        CREATE Order SET
            AccountId    = Quote.AccountId
            QuoteId__c  = Quote.Id
            ContractId  = Account.Contract.Id
            Status      = "Draft"
            EffectiveDate = Quote.StartDate
            OrderItems  = COPY QuoteLineItems
    ELSE
        THROW "Cannot create Order: no effective Contract found for Account"
    END IF
END IF
```

**Flow Metadata:**
```json
{
  "rules": [
    {
      "name": "CreateOrderOnApproval",
      "condition": "$Record.Status == 'Approved' && $Record__Old.Status != 'Approved'",
      "action": {
        "actionType": "CreateRelatedRecord",
        "relatedObject": "Order",
        "mapping": {
          "AccountId": "$Record.AccountId",
          "QuoteId__c": "$Record.Id",
          "Status": "Draft",
          "EffectiveDate": "$Record.StartDate"
        },
        "copyLineItems": {
          "from": "QuoteLineItem",
          "to": "OrderItem",
          "fields": ["Product2Id", "Quantity", "UnitPrice", "Discount"]
        }
      }
    }
  ]
}
```

**Critical Implementation Note:** The `$Record__Old` variable is only available in `RecordAfterSave` triggers. In `RecordBeforeSave`, only `$Record` (the new state) is available. The approval transition detection must therefore happen in `AfterSave` — but this means the Order creation occurs **after** the Quote save, in a separate transaction.

#### Trigger 5: Order_Check_Contract_BeforeCreate

**Object:** Order | **Event:** `RecordBeforeCreate` | **Priority:** P0

**Trigger Logic:**
```
IF Order.QuoteId__c != null THEN
    -- This Order was created from a Quote
    quote     = SELECT Quote WHERE Id == Order.QuoteId__c
    accountId = quote.AccountId

    contract  = SELECT Contract
                WHERE AccountId == accountId
                  AND Status    == 'Effective'
                LIMIT 1

    IF contract == null THEN
        THROW "Cannot create Order: no effective Contract found for Account"
    END IF

    Order.ContractId = contract.Id
END IF
```

**Note:** This trigger modifies `Order.ContractId` (writes the found ContractId onto the Order record) as a **side effect**. This is allowed in BeforeCreate triggers.

#### Trigger 7: Order_Activate_On_UploadPDF

**Object:** Order | **Event:** `RecordAfterSave` | **Priority:** P1

**Trigger Logic:**
```
IF Order.ArchivedOFPdfUrl CHANGED AND Order.ArchivedOFPdfUrl != null THEN
    IF Order.Status == "Draft" THEN
        Order.Status = "Activated"
    END IF
END IF
```

---

## 5. Database Schema

### 5.1 FlowDefinition (existing)

```sql
CREATE TABLE flowdefinition (
    id                    VARCHAR(36) PRIMARY KEY,
    developername         VARCHAR(255) NOT NULL UNIQUE,
    masterlabel           VARCHAR(255),
    triggertype          VARCHAR(40) NOT NULL,   -- RecordBeforeSave | RecordAfterSave | Scheduled | PlatformEvent
    triggerobjectorevent VARCHAR(80),            -- Object API name for record triggers
    activeversionid       VARCHAR(36),
    createddate          DATETIME,
    lastmodifieddate      DATETIME
);
```

### 5.2 FlowVersion (existing)

```sql
CREATE TABLE flowversion (
    id                   VARCHAR(36) PRIMARY KEY,
    flowdefinitionid     VARCHAR(36),
    developername        VARCHAR(255),
    versionnumber        INT,
    status              VARCHAR(20),   -- Active | Draft | InvalidDraft
    metadata            MEDIUMTEXT,     -- JSON flow definition
    createddate         DATETIME
);
```

### 5.3 Trigger Rules (optional extension)

For declarative configuration without writing Go code:

```sql
CREATE TABLE trigger_rules (
    id                  VARCHAR(36) PRIMARY KEY,
    object_api          VARCHAR(80) NOT NULL,
    event_type          VARCHAR(40) NOT NULL,  -- before_insert | after_update | etc.
    condition           TEXT,                    -- SOQL filter expression
    action_type         VARCHAR(40) NOT NULL,    -- call_handler | update_field | create_related
    handler_id          VARCHAR(36),             -- references trigger_handlers.id
    config              JSON,
    is_active           BOOLEAN DEFAULT TRUE,
    execute_order       INT DEFAULT 100,
    created_at          DATETIME,
    updated_at          DATETIME
);

CREATE TABLE trigger_handlers (
    id          VARCHAR(36) PRIMARY KEY,
    name        VARCHAR(255) NOT NULL,
    description TEXT,
    code        MEDIUMTEXT,    -- Go code as string (for eval) or reference to pkg function
    created_at  DATETIME
);
```

---

## 6. Implementation Plan

### Phase 1: Quote Field Auto-fill Triggers

**Goal:** Implement Triggers 1, 2, 3 for Quote.

**Steps:**
1. Insert `Quote_Set_Default_Status` FlowDefinition + FlowVersion into DB
2. Insert `Quote_Lock_Name_Number` FlowDefinition + FlowVersion into DB
3. Write E2E scenario: quote-default-status-e2e
4. Write E2E scenario: quote-locked-fields-e2e

**Files to modify:**
- Database: seed data for test accounts with Quotes
- `tests/e2e-api/scenarios/` — new scenario JSON files

### Phase 2: Quote-to-Order Flow

**Goal:** Implement Trigger 4 for Quote→Order creation.

**Steps:**
1. Insert `Quote_Create_Order_On_Approval` FlowDefinition + FlowVersion into DB
2. Verify `FlowTriggerListener` correctly detects `Status == "Approved"` transition
3. Ensure `FlowExecutor.CreateRecord` action properly copies QuoteLineItems to OrderItems
4. Write E2E scenario: quote-approval-to-order-e2e (extend existing SCN-QTO-001)

**Files to modify:**
- Database: FlowDefinition + FlowVersion SQL
- `tests/e2e-api/scenarios/quote-to-order.json` — update to use real flow

### Phase 3: Order Contract Validation

**Goal:** Implement Trigger 5 for Order creation validation.

**Steps:**
1. Write `OrderContractCheckHandler` in Go (registered as a BeforeCreate handler)
2. Add to `FlowTriggerListener` or as a standalone `BeforeCreate` handler in PersistenceService
3. Add E2E negative case: Order creation without Contract → expects 400 error

**Files to modify:**
- `backend/pkg/trigger/handlers/order_handlers.go` — new file
- `backend/internal/features/records/persistence_service.go` — integrate handler

### Phase 4: Order Activation on PDF Upload

**Goal:** Implement Trigger 7.

**Steps:**
1. Write `OrderActivateOnUploadHandler`
2. Register in FlowTriggerListener for `RecordAfterSave` on Order
3. Add E2E scenario: order-upload-pdf-activation-e2e

---

## 7. Anti-Patterns to Avoid

### 7.1 Trigger Loops

**Problem:** `Quote_Update_Status` → triggers `Order_Create` → `Order` update triggers `Quote_Update` → infinite loop.

**Solution:** All flow-triggered updates should set a context flag that skips re-triggering:

```go
// In PersistenceService.Insert/Update
if ctx.Value("__triggered_by_flow__") == true {
    // Skip re-publishing events for flow-triggered changes
    return nil
}
```

### 7.2 BeforeSave Side Effects

BeforeSave triggers should **only modify the triggering record**. Creating new records in BeforeSave can cause unexpected transaction behavior. Move related-record creation to AfterSave.

### 7.3 Synchronous Long-Running Operations

`FlowExecutor` runs synchronously in `FlowTriggerListener`. Long-running operations (> 5s) will block the HTTP response. Move slow operations to the async outbox:

```go
// Good: async for side effects
ps.outbox.EnqueueEvent(ctx, events.RecordCreated, payload)

// Bad: synchronous for slow operations
l.executor.ExecuteFlow(ctx, flowName, inputs, user)
```

### 7.4 Mixed Ownership

Triggers should not directly modify records owned by other services. Use the `ServiceContext` security mode and route through the appropriate service boundary.

---

## 8. Test Plan

### 8.1 Unit Tests

Each handler function must have a corresponding `_test.go` with:

- Happy path: record meets condition → action executed
- Error path: record fails condition → error returned correctly
- Edge case: null/missing fields → graceful handling

```go
func TestOrderContractCheckHandler(t *testing.T) {
    tests := []struct {
        name        string
        quoteId     string
        hasContract bool
        wantErr     bool
    }{
        {"with contract", "0Q0...", true, false},
        {"without contract", "0Q0...", false, true},
        {"no quote (direct order)", "", false, false},
    }
    // ...
}
```

### 8.2 E2E API Tests

```python
# File: tests/e2e-api/cases/order/
ord-contract-check-001.json   # No effective Contract → expects 400
ord-contract-check-002.json   # With effective Contract → expects 201
ord-status-transition-001.json # Draft → Activated (valid)
ord-status-transition-002.json # Activated → Draft (invalid, expects 400)
```

### 8.3 E2E Scenario Tests

Extend `SCN-QTO-001`:

```python
# File: tests/e2e-api/scenarios/quote-to-order.json
STEP 14: Create Order with NO effective Contract → expects 400
STEP 15: Upload PDF to Order → expects Status = Activated
STEP 16: Verify Quote Name cannot be edited → expects 400 on PATCH
```

---

## 9. Rollback Plan

All automation logic is stored in the database (`flowdefinition`, `flowversion` tables). To disable any trigger:

```sql
-- Disable by clearing ActiveVersionId
UPDATE flowdefinition SET activeversionid = NULL WHERE developername = 'Quote_Create_Order_On_Approval';

-- Or set status in FlowVersion
UPDATE flowversion SET status = 'InvalidDraft' WHERE developername = 'Quote_Create_Order_On_Approval';
```

No code deployment is required for rollback.

---

## 10. Open Questions

1. **Field-level trigger conditions:** Should `$Record__Old` be available in `BeforeSave` triggers? Currently it is only available in `AfterSave`. If the Quote→Order trigger needs to detect the `Status` transition specifically (not just the final state), this requires either `BeforeSave` access to `$Record__Old` or detecting the transition in `AfterSave` (current approach).

2. **Order creation from Quote:** Should the Order be created in the **same transaction** as the Quote approval (i.e., in `BeforeSave`) or a **separate transaction** (in `AfterSave`)? Current design uses `AfterSave` (separate transaction) — if Order creation fails, the Quote approval still succeeds.

3. **Contract lookup timing:** The `Order_Check_Contract_BeforeCreate` handler queries for an effective Contract at Order creation time. If the Contract is approved **after** the Order is created, the Order remains without a Contract. Should there be a periodic re-validation flow for this case?

4. **QuoteLineItem → OrderItem copy:** The `FlowExecutor` must support copying line items from a Quote to an Order. Is this implemented as a built-in action (`CopyRelatedLineItems`) or as a custom handler?

---

## 11. References

- `internal/features/automation/flow_trigger_listener.go` — existing trigger listener implementation
- `internal/features/automation/flow_executor.go` — flow action executor
- `shared/pkg/models/salesforce_flow.go` — FlowDefinition and FlowVersion models
- `internal/features/records/persistence_service.go` — event publishing (`publishRecordEvent`, `processPostUpdateHooks`)
- `internal/features/automation/approval_service.go` — existing Quote approval logic
- `tests/e2e-api/scenarios/quote-to-order.json` — E2E scenario for Quote→Order flow
