package models import ( "encoding/json" "time" "github.com/google/uuid" ) type Sequence struct { ID uuid.UUID `json:"id"` Name string `json:"name"` Subject string `json:"subject"` BodyPlain string `json:"body_plain"` BodyHTML string `json:"body_html"` BodySync bool `json:"body_sync"` BodyCode bool `json:"body_code"` WaitAfter int `json:"wait_after"` Position int `json:"position"` // X/Y are the step's canvas coordinates in the sequence builder. Persisted // so the arrangement sticks across visits; 0/0 means "not placed yet" (the // editor auto-arranges until a step is first dragged). Written only through // the layout endpoint, never the audited content update. X float64 `json:"x"` Y float64 `json:"y"` // Conditions is the per-step routing: the connections out of this step. // Routing follows connections only. When empty (`{}` / no branches) the // step has no outgoing path and the contact's flow ends there; position // orders the canvas and picks the entry step, it never advances a contact // by itself. When populated, the scheduler evaluates the contact's // engagement against these branches at schedule time to decide which step // (or stop) comes next. Stored as a single jsonb column on `sequences`. Conditions json.RawMessage `json:"conditions,omitempty"` // Kind is "email" (default — subject/body are rendered and sent) or a // non-email control node: "action" (Action.Type names the side effect) or // "wait" (delay only). Routing (Conditions) is identical regardless of Kind. Kind string `json:"kind"` // Action is the typed config for non-email nodes; an empty object for email // nodes. Stored in the sequences.action jsonb column. Action json.RawMessage `json:"action,omitempty"` UpdatedAt time.Time `json:"updated_at"` CreatedAt time.Time `json:"created_at"` } // ActionConfig is the persisted config for a non-email (action/wait) node. Type // is the switch the task executes on; the remaining fields are type-scoped. type ActionConfig struct { Type string `json:"type"` // wait | add_tag | remove_tag | label_email | unsubscribe | notify | create_task | create_deal | move_deal_stage | run_automation | fire_event | switch | ai_step | end // wait WaitMinutes *int `json:"wait_minutes,omitempty"` // add_tag / remove_tag — a contact category id (product "tags" == categories) CategoryID *uuid.UUID `json:"category_id,omitempty"` // label_email — apply unibox conversation labels to the contact's most recent // thread. Labels are the same registry as contact tags (categories), but in // the inbox they're "labels", so the field is label_ids. Reply-branch only. LabelIDs []uuid.UUID `json:"label_ids,omitempty"` // create_task — open a CRM task for the lead when they reach this step // (e.g. a Call task). TaskAssignedTo is the teammate chosen on the step; // when nil the task falls back to the campaign owner. TaskTitle string `json:"task_title,omitempty"` TaskType string `json:"task_type,omitempty"` // general | call | email | meeting TaskPriority string `json:"task_priority,omitempty"` // low | medium | high | urgent TaskAssignedTo *uuid.UUID `json:"task_assigned_to,omitempty"` TaskAssignedTeamID *uuid.UUID `json:"task_assigned_team_id,omitempty"` // assign to a whole team instead of one user TaskDueOffsetDays *int `json:"task_due_offset_days,omitempty"` // due N days after the step fires // create_deal / move_deal_stage — CRM deal automation off a reply branch. // create_deal: open a new deal for the contact in DealPipelineID/DealStageID. // move_deal_stage: move the contact's most-recent OPEN deal in // DealPipelineID to DealStageID; a contact with no open deal in that // pipeline is a logged no-op (not an error). // DealName supports the same {{first_name}}/{{company}} templating other // campaign copy uses. DealValue is optional; DealCurrency defaults to "USD". DealPipelineID *uuid.UUID `json:"deal_pipeline_id,omitempty"` DealStageID *uuid.UUID `json:"deal_stage_id,omitempty"` DealName string `json:"deal_name,omitempty"` DealValue *float64 `json:"deal_value,omitempty"` DealCurrency string `json:"deal_currency,omitempty"` // run_automation — launch an automation flow when the contact reaches this // step, passing templated key/value inputs as the automation's event data. // Values render against the contact ({{.FirstName}} / {{.Company}} etc.). AutomationID *uuid.UUID `json:"automation_id,omitempty"` AutomationValues []ActionKV `json:"automation_values,omitempty"` // fire_event — publish a developer-defined custom event to the realtime // gateway. Subscribers (an API key with REALTIME_SUBSCRIBE on the org // websocket) receive it with no public URL. EventName + each field value are // templated against the contact; the fields become the event payload. EventName string `json:"event_name,omitempty"` EventFields []ActionKV `json:"event_fields,omitempty"` // switch — a multi-way router. SwitchCases are the named case paths shown // as draggable dots on the canvas node; each connected case is stored as an // outgoing branch with an "ai_label" condition carrying the case name, and // an unconditional branch is the "otherwise" fallback. SwitchOn picks the // decider: // "ai" — one model call per contact follows AIInstruction (templated // against the contact) and picks EXACTLY one case. Costs one AI // credit per contact. // "value" — SwitchValue (a template like {{.Industry}}) is rendered // against the contact and matched to the case names. Free, // deterministic, no model call. // Either way the chosen case lands on the progress row (RecordAILabel) and // routing reads it at the step boundary; a contact matching no case follows // the fallback. Side effects are ordinary action steps placed on the chosen // path, never executed by the decider. Always runs through the scheduler // (never the instant chain), so an instant branch pauses at it. SwitchOn string `json:"switch_on,omitempty"` // "ai" (default) | "value" SwitchCases []string `json:"switch_cases,omitempty"` SwitchValue string `json:"switch_value,omitempty"` AIInstruction string `json:"ai_instruction,omitempty"` // AI-decider capabilities. AIWebSearch runs one bounded web search about // the contact's company before deciding and feeds the results in as fenced // untrusted context (+1 credit when results are found). AIThinking routes // the call to the stronger model tier with a larger output budget; the // extra cost flows through usage metering. AIWebSearch bool `json:"ai_web_search,omitempty"` AIThinking bool `json:"ai_thinking,omitempty"` // Context opt-outs for the AI-decided switch. By default the model also // sees the contact's campaign history (which steps ran, opens/clicks/ // replies, prior outcomes) and the newest email received from them, so // decisions can be grounded in "what happened so far". Stored inverted so // existing steps keep the richer context. AINoEngagement bool `json:"ai_no_engagement,omitempty"` AINoReplies bool `json:"ai_no_replies,omitempty"` // ai_step (agent) — a bounded AI agent that follows AIInstruction and may // call the reversible actions in AIAllowedActions (add_tag, remove_tag, // label_email, unsubscribe, create_task, create_deal, move_deal_stage). Most // enabled actions' pinned config lives in this same ActionConfig blob (the // Deal* / Task* / LabelIDs fields above). The agent decides which to run per // contact; it never sends or replies. Billed per iteration. AIAllowedActions []string `json:"ai_allowed_actions,omitempty"` // AIAddTags / AIRemoveTags / AILabels are OPTIONAL pools the agent picks from // by name. An empty pool means unrestricted: the executor lists the org's // tags/labels live at run time and the agent may use any (tags and unibox // labels are the same category registry). AIAllowCreateTags additionally lets // an empty-pool pick mint a brand-new tag/label (opt-in). AIAddTags []AITagRef `json:"ai_add_tags,omitempty"` AIRemoveTags []AITagRef `json:"ai_remove_tags,omitempty"` AILabels []AITagRef `json:"ai_labels,omitempty"` AIAllowCreateTags bool `json:"ai_allow_create_tags,omitempty"` } // AITagRef is one tag in an AI agent step's add/remove pool (id + display name). type AITagRef struct { ID string `json:"id"` Name string `json:"name"` } // IsReversibleCampaignAction is the closed set of reversible action types a // campaign AI agent step (ai_step) may call as a guarded tool. Its own list // (never derived) so it can never include a send, run_automation, fire_event, // switch, wait, or end. Shared by write validation and the step executor. func IsReversibleCampaignAction(t string) bool { switch t { case "add_tag", "remove_tag", "label_email", "unsubscribe", "create_task", "create_deal", "move_deal_stage": return true default: return false } } // ActionKV is one templated input passed to a launched automation. type ActionKV struct { Key string `json:"key"` Value string `json:"value"` } type UpdateSequence struct { Name *string `json:"name"` Subject *string `json:"subject"` BodyPlain *string `json:"body_plain"` BodyHTML *string `json:"body_html"` BodySync *bool `json:"body_sync"` BodyCode *bool `json:"body_code"` WaitAfter *int `json:"wait_after"` // Conditions, when non-nil, replaces the step's branching tree. Send `{}` // (or an object with an empty `branches` array) to clear branching and fall // back to linear progression. Conditions *BranchConditions `json:"conditions"` // Kind / Action, when non-nil, switch the node between email and action/wait. Kind *string `json:"kind"` Action *ActionConfig `json:"action"` } // SequenceLayout is a position-only update for the sequence builder: the canvas // x/y of some or all steps. Like the automation layout endpoint it is written // continuously as steps are dragged and is deliberately NOT audited and does not // bump updated_at — a reposition is cosmetic. Retries are naturally safe // (positions are last-write-wins), so it needs no Idempotency-Key. type SequenceLayout struct { Positions []SequencePosition `json:"positions"` } // SequencePosition is one step's canvas coordinates. type SequencePosition struct { ID string `json:"id"` X float64 `json:"x"` Y float64 `json:"y"` } // BranchConditions is the typed branching tree persisted in the sequence // `conditions` jsonb column. Branches are evaluated in declared order; the first // branch whose conditions ALL match wins. A winning branch routes the contact to // its TargetSequenceID (any step in the campaign), or stops them when the target // is nil. When no branch matches (or Branches is empty) the contact's flow // ends at this step; a plain "go there next" link is a branch with no // conditions. type BranchConditions struct { Branches []Branch `json:"branches,omitempty"` } // Branch is a single conditional route out of a step ("if -> go to // target, else stop"). A branch with no conditions is an unconditional catch-all // ("otherwise"). type Branch struct { // BranchID is a stable client-supplied identifier (for editor diffing / // logging). Kept as a free-form string: the editor uses crypto.randomUUID() // when available but falls back to a non-UUID token, so this must NOT be a // strict uuid.UUID or unmarshalling the PATCH body would fail. BranchID string `json:"branch_id"` // TargetSequenceID is the step to route to when this branch matches. nil // means STOP (send the contact no further step). A target that no longer // exists (a deleted step) is treated as STOP at schedule time. TargetSequenceID *uuid.UUID `json:"target_step_id"` // Conditions are ANDed together — every condition must hold for the branch // to match. An empty list is an unconditional/catch-all branch ("otherwise"). Conditions []BranchCondition `json:"conditions,omitempty"` // Instant, for a reply_* branch, controls whether its action chain fires the // MOMENT the contact replies. nil or true = instant (the default); false = // opt out, leaving the branch to route at the normal step boundary like an // engagement branch. Ignored for non-reply branches. Stored in the // sequences.conditions jsonb, so no migration is needed. Instant *bool `json:"instant,omitempty"` } // BranchCondition is a single engagement predicate evaluated against the // contact's campaign_contact_progress row for the current step. type BranchCondition struct { // Field is the engagement signal: // "opened" | "clicked" | "replied" and their negations // "not_opened" | "not_clicked" | "not_replied", // plus the reply-classification fields (operator "ever", no Value), read // from campaign_contact_progress.reply_class: // "reply_positive" — reply_class is positive // "reply_negative" — reply_class is negative // "reply_neutral" — reply_class is neutral // "reply_automated" — reply_class is auto_reply OR out_of_office // IMPORTANT: the plain "replied"/"not_replied" fields IGNORE automated // replies (auto_reply / out_of_office) — only a human reply sets replied_at, // so a vacation autoresponder never trips "replied" or stop_on_reply. Use the // reply_automated field to branch specifically on an automated reply. // // "ai_label" (operator "is", value in Label) matches when the AI step that // owns this branch stored that label for the contact — deterministic at // schedule time, no model call. Field string `json:"field"` // Operator is the comparison. "within_days" (the signal occurred in the last // Value days) and "ever" (the signal occurred at all). For the not_* fields // the meaning inverts (did NOT happen within / ever). The reply_* fields take // operator "ever" (no Value). "is" pairs with "ai_label" (compare to Label). Operator string `json:"operator"` // Value is the day window for "within_days". nil for operators that take no // argument (e.g. "ever"). Value *int `json:"value"` // Label is the AI-step label an "ai_label" condition compares against // (case-insensitive). Empty for every other field. Label string `json:"label,omitempty"` }