package models import ( "encoding/json" "time" "github.com/google/uuid" ) // IntegrationProvider identifies one third-party system Warmbly can connect // to. Adding a new provider here is enough to make it visible in the // dashboard's catalog. The actual connect/disconnect logic is handled in // the integration service's per-provider switch. type IntegrationProvider string const ( // CRM IntegrationHubSpot IntegrationProvider = "hubspot" IntegrationSalesforce IntegrationProvider = "salesforce" IntegrationPipedrive IntegrationProvider = "pipedrive" IntegrationClose IntegrationProvider = "close" // Automation IntegrationZapier IntegrationProvider = "zapier" IntegrationMake IntegrationProvider = "make" IntegrationN8N IntegrationProvider = "n8n" // Notifications IntegrationSlack IntegrationProvider = "slack" IntegrationDiscord IntegrationProvider = "discord" // Meetings IntegrationCalendly IntegrationProvider = "calendly" IntegrationCalCom IntegrationProvider = "cal_com" // Data IntegrationGoogleSheets IntegrationProvider = "google_sheets" // Verification IntegrationMillionVerifier IntegrationProvider = "millionverifier" ) // AllIntegrationProviders lists every provider the dashboard exposes. The // order here is the catalog order users see. var AllIntegrationProviders = []IntegrationProvider{ IntegrationHubSpot, IntegrationSalesforce, IntegrationPipedrive, IntegrationClose, IntegrationZapier, IntegrationMake, IntegrationN8N, IntegrationSlack, IntegrationDiscord, IntegrationCalendly, IntegrationCalCom, IntegrationGoogleSheets, IntegrationMillionVerifier, } func IsValidIntegrationProvider(s string) bool { for _, p := range AllIntegrationProviders { if string(p) == s { return true } } return false } // IntegrationAuthMethod describes how a connection is authenticated. type IntegrationAuthMethod string const ( // IntegrationAuthOAuth is a real OAuth 2.0 authorization-code handshake. // The user clicks "Connect", authorizes in the provider's popup, and we // store an encrypted access/refresh token pair — no pasting credentials. IntegrationAuthOAuth IntegrationAuthMethod = "oauth" // IntegrationAuthAPIKey is a provider-issued API token the user pastes // (used only where the provider offers no OAuth app, e.g. Close). IntegrationAuthAPIKey IntegrationAuthMethod = "api_key" // IntegrationAuthWebhook is an inbound URL Warmbly mints (Calendly, Cal.com) // or an outbound URL the user pastes (Discord). IntegrationAuthWebhook IntegrationAuthMethod = "webhook" ) // IntegrationStatus is the lifecycle state of a connection. type IntegrationStatus string const ( // IntegrationStatusPending — row created, not yet usable. IntegrationStatusPending IntegrationStatus = "pending" // IntegrationStatusAuthorizing — OAuth handshake is mid-flight. IntegrationStatusAuthorizing IntegrationStatus = "authorizing" // IntegrationStatusConnected — healthy, last interaction succeeded. IntegrationStatusConnected IntegrationStatus = "connected" // IntegrationStatusDegraded — connected but the last call errored. IntegrationStatusDegraded IntegrationStatus = "degraded" // IntegrationStatusReauthRequired — token revoked/expired; user must reconnect. IntegrationStatusReauthRequired IntegrationStatus = "reauth_required" // IntegrationStatusDisconnected — intentionally removed. IntegrationStatusDisconnected IntegrationStatus = "disconnected" ) // IntegrationHealth is the rolling health signal surfaced on the connection // card, independent of the lifecycle status (a connection can be 'connected' // but 'degraded' health after a transient provider error). type IntegrationHealth string const ( IntegrationHealthUnknown IntegrationHealth = "unknown" IntegrationHealthHealthy IntegrationHealth = "healthy" IntegrationHealthDegraded IntegrationHealth = "degraded" IntegrationHealthDown IntegrationHealth = "down" ) // IntegrationCategory groups providers in the dashboard. type IntegrationCategory string const ( IntegrationCategoryCRM IntegrationCategory = "crm" IntegrationCategoryAutomation IntegrationCategory = "automation" IntegrationCategoryNotifications IntegrationCategory = "notifications" IntegrationCategoryMeetings IntegrationCategory = "meetings" IntegrationCategoryData IntegrationCategory = "data" IntegrationCategoryVerification IntegrationCategory = "verification" ) // IntegrationCatalogEntry is the static metadata for one provider that the // dashboard renders even when no connection exists yet. type IntegrationCatalogEntry struct { Provider IntegrationProvider `json:"provider"` Name string `json:"name"` Tagline string `json:"tagline"` Category IntegrationCategory `json:"category"` DocsURL string `json:"docs_url,omitempty"` AuthMethod string `json:"auth_method"` // 'oauth' | 'api_key' | 'webhook' BadgeColor string `json:"badge_color,omitempty"` BetaFlag bool `json:"beta"` // WebhookHint is shown to webhook-URL providers (Discord) and inbound // providers (Calendly, Cal.com). WebhookHint string `json:"webhook_hint,omitempty"` // Highlights are short "what you get" bullets shown on the provider's // detail screen during onboarding. Highlights []string `json:"highlights,omitempty"` // Scopes is the set of OAuth scopes requested at authorize time. Empty // for non-OAuth providers. Surfaced so the consent screen is honest. Scopes []string `json:"scopes,omitempty"` // Events lists the Warmbly events this provider can react to (so the UI // can offer "notify on positive reply", etc). Events []string `json:"events,omitempty"` // ActionTypes lists the provider action identifiers that have a real // backend handler. The dashboard reads this so the automation builder only // offers actions Warmbly can actually execute (a provider with no action // types should not surface an automation/subscription builder at all). ActionTypes []string `json:"action_types,omitempty"` // SupportsPush reports whether this provider can be the target of the // synchronous "push selected contacts" action surfaced contextually in the // dashboard (Contacts, Deals). True only for CRM providers with an upsert // handler. SupportsPush bool `json:"supports_push"` // Capability is the configurable-action descriptor the dashboard renders the // onboarding + field-mapping UI from. Nil for providers with no descriptor. Capability *ProviderCapability `json:"capability,omitempty"` // Configured reports whether the server has OAuth client credentials wired // for this provider. OAuth providers without credentials render as // "coming soon" instead of a dead Connect button. Configured bool `json:"configured"` } // IntegrationConnection is one org's link to one provider. Secrets // (access/refresh tokens, pasted API keys) are never serialized here — only // the encrypted columns in the DB hold them. type IntegrationConnection struct { ID uuid.UUID `json:"id"` OrganizationID uuid.UUID `json:"organization_id"` Provider IntegrationProvider `json:"provider"` Label string `json:"label"` Status IntegrationStatus `json:"status"` AuthMethod string `json:"auth_method"` DisplayFields json.RawMessage `json:"display_fields"` // ConfigCapabilities is the per-connection onboarding/capability snapshot // (selected objects, enabled use-cases, picker selections). Non-secret, read // before execution. Distinct from the sealed secrets in config_encrypted. ConfigCapabilities json.RawMessage `json:"config_capabilities,omitempty"` // SyncDirection is the connection's data-flow direction: push | pull | both. SyncDirection string `json:"sync_direction"` ConnectedByUserID *uuid.UUID `json:"connected_by_user_id,omitempty"` ExternalAccountID string `json:"external_account_id,omitempty"` ExternalAccountName string `json:"external_account_name,omitempty"` GrantedScopes []string `json:"granted_scopes,omitempty"` TokenExpiresAt *time.Time `json:"token_expires_at,omitempty"` Health string `json:"health"` HealthDetail *string `json:"health_detail,omitempty"` HealthCheckedAt *time.Time `json:"health_checked_at,omitempty"` LastSyncedAt *time.Time `json:"last_synced_at,omitempty"` LastError *string `json:"last_error,omitempty"` LastErrorAt *time.Time `json:"last_error_at,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` // Returned only at create time for providers that POST inbound // (Calendly, Cal.com). The dashboard surfaces the resulting URL once. InboundWebhookURL string `json:"inbound_webhook_url,omitempty"` } // IntegrationTokens carries the freshly-exchanged OAuth material an // implementation persists. Plaintext lives only in memory. type IntegrationTokens struct { AccessToken string RefreshToken string ExpiresAt *time.Time Scopes []string } // IntegrationOAuthState is the short-lived CSRF/PKCE record minted at the // start of an OAuth handshake and consumed on callback. type IntegrationOAuthState struct { ID uuid.UUID OrganizationID uuid.UUID UserID uuid.UUID Provider IntegrationProvider State string CodeVerifier string Label string RequestedScopes []string UsedAt *time.Time ExpiresAt time.Time CreatedAt time.Time } // IntegrationOAuthStartResponse is returned to the SPA so it can open the // provider authorization popup. type IntegrationOAuthStartResponse struct { URL string `json:"url"` State string `json:"state"` } // IntegrationAction is a provider-specific side effect fired by an event // subscription. type IntegrationAction string const ( IntegrationActionSlackNotify IntegrationAction = "slack.notify" IntegrationActionDiscordNotify IntegrationAction = "discord.notify" IntegrationActionHubSpotUpsert IntegrationAction = "hubspot.upsert_contact" IntegrationActionPipedriveUpsert IntegrationAction = "pipedrive.upsert_person" IntegrationActionSalesforceUpsert IntegrationAction = "salesforce.upsert_contact" IntegrationActionCloseUpsert IntegrationAction = "close.upsert_lead" IntegrationActionGenericWebhookPing IntegrationAction = "webhook.ping" // Native (Warmbly-internal) actions: CRM/contact mutations that need no // external connection. Run against the contact resolved from the event data. IntegrationActionAddTag IntegrationAction = "warmbly.add_tag" IntegrationActionRemoveTag IntegrationAction = "warmbly.remove_tag" IntegrationActionCreateTask IntegrationAction = "warmbly.create_task" IntegrationActionCreateDeal IntegrationAction = "warmbly.create_deal" IntegrationActionMoveDealStage IntegrationAction = "warmbly.move_deal_stage" IntegrationActionUnsubscribe IntegrationAction = "warmbly.unsubscribe" // IntegrationActionLabelEmail applies unibox conversation labels (categories) // to the thread the event belongs to. Reply triggers carry the thread_id + // mailbox owner; on other triggers (no thread) it is a logged no-op. IntegrationActionLabelEmail IntegrationAction = "warmbly.label_email" // IntegrationActionRunAutomation launches another automation's flow, passing // the current event data through. Bounded by the chain-depth guard so it // cannot loop forever or fan out unbounded compute. IntegrationActionRunAutomation IntegrationAction = "warmbly.run_automation" // IntegrationActionSetVariables computes one or more named values from Go // templates (against the event + prior step output) and writes them back into // the event data, so later nodes can reuse a transformed/normalized value // without recomputing it. The safe "transform" node — it runs the same // sandboxed text/template engine as every other action value (no I/O, no // arbitrary code), not a general code runtime. IntegrationActionSetVariables IntegrationAction = "warmbly.set_variables" // IntegrationActionFireEvent publishes a developer-defined custom event to the // realtime gateway (org-scoped). The event name + a fully-custom key/value // payload are Go-templated against the event data. Subscribers (an API key // with REALTIME_SUBSCRIBE on the org websocket) receive it with no public URL, // so it replaces an outbound webhook for "tell my system this happened". IntegrationActionFireEvent IntegrationAction = "warmbly.fire_event" // IntegrationActionUpsertContact creates a contact from templated event // fields, or enriches the one already holding that email, then tags it and // enrols it in a campaign. The lead-intake action: an inbound webhook or a // form submission becomes a contact without leaving Warmbly. IntegrationActionUpsertContact IntegrationAction = "warmbly.upsert_contact" // IntegrationActionAddToCampaign enrols the event's contact in a campaign. IntegrationActionAddToCampaign IntegrationAction = "warmbly.add_to_campaign" // AI nodes mirror the campaign step types: one unified AI step plus an AI // switch router. // // IntegrationActionAIStep is the unified AI node. config.mode selects the // behavior: classify | extract | generate (single LLM call over the event, // merged into data so downstream conditions can branch) or agent (a bounded // tool-use loop where the model, following config.instruction, calls a // guarded allowlist of reversible native actions in config.allowed_actions[], // choosing tags/tasks/deals itself). config.thinking routes any mode to the // stronger model tier. IntegrationActionAIStep IntegrationAction = "warmbly.ai_step" // IntegrationActionAISwitch is a campaign-style multi-way router: it picks // exactly one of config.cases[], stored so the "label:" edges route the // walk. config.switch_on selects the decider — "ai" (one LLM call, optional // web search + thinking) or "value" (config.switch_value rendered against the // event and matched to the case names; free, no model call). Decide-only; it // takes no actions. IntegrationActionAISwitch IntegrationAction = "warmbly.ai_switch" ) // IsAIAction reports whether an action is an AI node (LLM-backed, credit-charged). func IsAIAction(a IntegrationAction) bool { switch a { case IntegrationActionAIStep, IntegrationActionAISwitch: return true default: return false } } // IsNativeAction reports whether an action is a Warmbly-internal CRM/contact // mutation (no external connection required). func IsNativeAction(a IntegrationAction) bool { switch a { case IntegrationActionAddTag, IntegrationActionRemoveTag, IntegrationActionCreateTask, IntegrationActionCreateDeal, IntegrationActionMoveDealStage, IntegrationActionUnsubscribe, IntegrationActionRunAutomation, IntegrationActionLabelEmail, IntegrationActionSetVariables, IntegrationActionFireEvent, IntegrationActionUpsertContact, IntegrationActionAddToCampaign, IntegrationActionAIStep, IntegrationActionAISwitch: return true default: return false } } // IntegrationEventSubscription routes a Warmbly event to a provider action. type IntegrationEventSubscription struct { ID uuid.UUID `json:"id"` ConnectionID uuid.UUID `json:"connection_id"` OrganizationID uuid.UUID `json:"organization_id"` EventType string `json:"event_type"` Action IntegrationAction `json:"action"` Config json.RawMessage `json:"config"` Enabled bool `json:"enabled"` // UseCase is a discriminator describing what this automation is for (e.g. // "crm_sync", "notify", "custom"). Drives projection + which handler runs. UseCase string `json:"use_case"` // AutomationID groups this subscription as one step of an Automation (the // visual flow builder). Nil = a legacy/standalone subscription. AutomationID *uuid.UUID `json:"automation_id,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // Automation is a branching flow: "when fires, walk this graph" // — the data behind the visual flow builder. The graph holds a trigger node, // condition (IF) nodes, and action nodes connected by edges; the executor walks // it on each matching event, evaluating conditions and running the action nodes // on the matched paths (reusing the event-subscription action handlers). type Automation struct { ID uuid.UUID `json:"id"` OrganizationID uuid.UUID `json:"organization_id"` Name string `json:"name"` Enabled bool `json:"enabled"` TriggerEvent string `json:"trigger_event"` // Filter is an optional automation-wide gate (intents / min_confidence) // applied to every action, on top of any condition nodes. Filter json.RawMessage `json:"filter,omitempty"` Graph AutomationGraph `json:"graph"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` // InboundToken is the per-automation secret embedded in the inbound-webhook // URL, set only when TriggerEvent is inbound.webhook. Server-only; the URL // that carries it is surfaced to clients via InboundURL instead. InboundToken string `json:"-"` // InboundURL is the public POST path that fires this automation when its // trigger is the inbound webhook. Computed from InboundToken, never stored. InboundURL string `json:"inbound_url,omitempty"` } // AutomationGraph is the editable flow: nodes + the edges connecting them. type AutomationGraph struct { Nodes []AutomationNode `json:"nodes"` Edges []AutomationEdge `json:"edges"` } // AutomationNode is one node on the canvas. Type is "trigger" (exactly one, id // "trigger"), "condition" (an IF with true/false outgoing edges), or "action". type AutomationNode struct { ID string `json:"id"` Type string `json:"type"` Action IntegrationAction `json:"action,omitempty"` // action nodes ConnectionID *uuid.UUID `json:"connection_id,omitempty"` // action nodes Config json.RawMessage `json:"config,omitempty"` // action node config Condition *AutomationCondition `json:"condition,omitempty"` // condition nodes X float64 `json:"x"` Y float64 `json:"y"` } // AutomationEdge connects two nodes. When is "" for plain edges (from the // trigger or after an action) and "true"/"false" for the two outgoing edges of // a condition node. type AutomationEdge struct { ID string `json:"id"` Source string `json:"source"` Target string `json:"target"` When string `json:"when,omitempty"` } // AutomationCondition is an IF test evaluated against the trigger event's data. // For the generic "field" type, Key names the event-data key to test. For the // "expression" type, Expression is a Go-template predicate (truthy when it // renders a non-empty, non-false value) evaluated against the native event data, // giving full string/number/boolean logic. For the "ai" type, Prompt is a // plain-language yes/no question the model answers over the event data (the // true edge is YES, false is NO; costs one AI credit per evaluation). type AutomationCondition struct { Field string `json:"field"` Key string `json:"key,omitempty"` Operator string `json:"operator"` Value any `json:"value,omitempty"` Expression string `json:"expression,omitempty"` Prompt string `json:"prompt,omitempty"` } // AutomationWrite is the create/update payload from the flow builder. type AutomationWrite struct { Name string `json:"name"` Enabled bool `json:"enabled"` TriggerEvent string `json:"trigger_event"` Filter json.RawMessage `json:"filter,omitempty"` Graph AutomationGraph `json:"graph"` } // AutomationLayout is a position-only update: the canvas x/y of some or all // nodes, persisted without rewriting the rest of the graph (logic, edges, // config) and without bumping updated_at. It lets a teammate's arrangement // "stick" across visits and is written continuously as nodes are dragged (the // live cursor/drag stream is the realtime half). Retries are naturally safe — // positions are last-write-wins with no accumulation — so it needs no // Idempotency-Key. type AutomationLayout struct { Positions []AutomationNodePosition `json:"positions"` } // AutomationNodePosition is one node's canvas coordinates. type AutomationNodePosition struct { ID string `json:"id"` X float64 `json:"x"` Y float64 `json:"y"` } // AutomationRun is one execution of an automation graph (per fired event or // manual launch), with per-node outcomes for the builder's history panel. type AutomationRun struct { ID uuid.UUID `json:"id"` AutomationID uuid.UUID `json:"automation_id"` OrganizationID uuid.UUID `json:"organization_id"` TriggerEvent string `json:"trigger_event"` Status string `json:"status"` // running | success | error NodeResults []AutomationNodeResult `json:"node_results"` ErrorDetail string `json:"error_detail,omitempty"` StartedAt time.Time `json:"started_at"` FinishedAt *time.Time `json:"finished_at,omitempty"` } // AutomationNodeResult is one node's outcome in a run (or a dry-run trace). type AutomationNodeResult struct { NodeID string `json:"node_id"` Type string `json:"type"` // trigger | condition | action Action string `json:"action,omitempty"` // action nodes Label string `json:"label,omitempty"` // human summary (e.g. "Slack · #sales") Status string `json:"status"` // success | error | skipped | branch_true | branch_false Error string `json:"error,omitempty"` Preview map[string]any `json:"preview,omitempty"` // dry-run: what the action would send } // DryRunRequest tests an automation without side effects. Data is the sample // event payload; when empty the server builds a sample from the trigger. // SkipNodeIDs are action nodes the caller toggled off for this test: they are // recorded as "skipped" in the trace and never previewed. type DryRunRequest struct { Data map[string]any `json:"data,omitempty"` SkipNodeIDs []string `json:"skip_node_ids,omitempty"` } // DryRunResponse is the trace of a dry run. type DryRunResponse struct { Trace []AutomationNodeResult `json:"trace"` Data map[string]any `json:"data"` } // IntegrationFieldMapping is one Warmbly-field -> provider-field mapping row. // SubscriptionID scopes a mapping to a single automation; when nil the mapping // is a connection default applied to every automation for that object/direction. type IntegrationFieldMapping struct { ID uuid.UUID `json:"id"` ConnectionID uuid.UUID `json:"connection_id"` OrganizationID uuid.UUID `json:"organization_id"` SubscriptionID *uuid.UUID `json:"subscription_id,omitempty"` Direction string `json:"direction"` ObjectName string `json:"object_name"` WarmblyField string `json:"warmbly_field"` ExternalField string `json:"external_field"` Transform string `json:"transform"` StaticValue string `json:"static_value"` IsDefault bool `json:"is_default"` CreatedAt time.Time `json:"created_at"` } // IntegrationSyncRun is one observability record of work done against a // connection (connect, token refresh, event dispatch, manual sync). type IntegrationSyncRun struct { ID uuid.UUID `json:"id"` ConnectionID uuid.UUID `json:"connection_id"` OrganizationID uuid.UUID `json:"organization_id"` Kind string `json:"kind"` Status string `json:"status"` Detail string `json:"detail"` RecordsProcessed int `json:"records_processed"` StartedAt time.Time `json:"started_at"` FinishedAt *time.Time `json:"finished_at,omitempty"` } // MeetingBookingStatus is the lifecycle state of a booked meeting. type MeetingBookingStatus string const ( MeetingBooked MeetingBookingStatus = "booked" MeetingRescheduled MeetingBookingStatus = "rescheduled" MeetingCanceled MeetingBookingStatus = "canceled" MeetingCompleted MeetingBookingStatus = "completed" MeetingNoShow MeetingBookingStatus = "no_show" ) // MeetingBooking represents one booked meeting from Calendly/Cal.com, tracked // through its full lifecycle (booked -> rescheduled / canceled). type MeetingBooking struct { ID uuid.UUID `json:"id"` OrganizationID uuid.UUID `json:"organization_id"` Source string `json:"source"` ExternalEventID string `json:"external_event_id"` Status MeetingBookingStatus `json:"status"` InviteeEmail string `json:"invitee_email"` InviteeName string `json:"invitee_name"` EventName string `json:"event_name"` EventType string `json:"event_type,omitempty"` ScheduledFor *time.Time `json:"scheduled_for,omitempty"` EndTime *time.Time `json:"end_time,omitempty"` JoinURL string `json:"join_url,omitempty"` Location string `json:"location,omitempty"` CancelURL string `json:"cancel_url,omitempty"` RescheduleURL string `json:"reschedule_url,omitempty"` CanceledReason string `json:"canceled_reason,omitempty"` ContactID *uuid.UUID `json:"contact_id,omitempty"` CampaignID *uuid.UUID `json:"campaign_id,omitempty"` RawPayload json.RawMessage `json:"raw_payload,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` // Joined for list display (not stored on the row). ContactName string `json:"contact_name,omitempty"` } // MeetingBookingFilter scopes a Meetings-page search. type MeetingBookingFilter struct { // Timeframe: "upcoming" (scheduled_for >= now, not canceled), // "past" (scheduled_for < now), or "" for all. Timeframe string Status string // exact status filter, or "" for any Search string // matches invitee name/email or event name Limit int Offset int } // MeetingBookingSummary powers the sidebar count + page header stats. type MeetingBookingSummary struct { Upcoming int `json:"upcoming"` Today int `json:"today"` Total int `json:"total"` Canceled int `json:"canceled"` } // MeetingBookingPage is a meetings result. Offset pagination under the hood, but // it exposes the standard {total, next_cursor, has_more} envelope with an OPAQUE // cursor like every other list (Total is exact so the UI can show "N of M"). type MeetingBookingPage struct { Data []MeetingBooking `json:"data"` Pagination Pagination `json:"pagination"` }