2025-12-22 14:08:30 +05:30
|
|
|
import uuid
|
2025-09-09 14:37:32 +05:30
|
|
|
from datetime import UTC, datetime
|
|
|
|
|
|
2026-01-17 14:37:03 +05:30
|
|
|
from pgvector.sqlalchemy import Vector
|
2025-09-09 14:37:32 +05:30
|
|
|
from sqlalchemy import (
|
|
|
|
|
JSON,
|
|
|
|
|
Boolean,
|
|
|
|
|
Column,
|
|
|
|
|
DateTime,
|
|
|
|
|
Enum,
|
|
|
|
|
Float,
|
|
|
|
|
ForeignKey,
|
|
|
|
|
Index,
|
|
|
|
|
Integer,
|
|
|
|
|
String,
|
|
|
|
|
Table,
|
2026-01-17 14:37:03 +05:30
|
|
|
Text,
|
2025-09-09 14:37:32 +05:30
|
|
|
UniqueConstraint,
|
|
|
|
|
and_,
|
2026-06-02 13:43:20 +05:30
|
|
|
func,
|
2025-09-09 14:37:32 +05:30
|
|
|
text,
|
|
|
|
|
)
|
|
|
|
|
from sqlalchemy.orm import declarative_base, relationship
|
|
|
|
|
|
2026-01-29 11:57:57 +05:30
|
|
|
from api.constants import DEFAULT_CAMPAIGN_RETRY_CONFIG
|
|
|
|
|
|
2025-12-22 14:08:30 +05:30
|
|
|
from ..enums import (
|
2026-01-12 10:10:30 +05:30
|
|
|
CallType,
|
2025-12-22 14:08:30 +05:30
|
|
|
IntegrationAction,
|
2026-01-02 13:11:02 +05:30
|
|
|
ToolCategory,
|
|
|
|
|
ToolStatus,
|
2025-12-22 14:08:30 +05:30
|
|
|
TriggerState,
|
|
|
|
|
WebhookCredentialType,
|
|
|
|
|
WorkflowRunState,
|
|
|
|
|
WorkflowStatus,
|
|
|
|
|
)
|
2025-09-09 14:37:32 +05:30
|
|
|
|
|
|
|
|
Base = declarative_base()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# TODO: remove workflow_defintion after migration, remove nullable workflow_defintion_id from Workflow and Workflowrun
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Association table for many-to-many relationship between users and organizations
|
|
|
|
|
organization_users_association = Table(
|
|
|
|
|
"organization_users",
|
|
|
|
|
Base.metadata,
|
|
|
|
|
Column("user_id", Integer, ForeignKey("users.id"), primary_key=True),
|
|
|
|
|
Column(
|
|
|
|
|
"organization_id", Integer, ForeignKey("organizations.id"), primary_key=True
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class UserModel(Base):
|
|
|
|
|
__tablename__ = "users"
|
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
|
provider_id = Column(String, unique=True, index=True, nullable=False)
|
|
|
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
|
|
|
workflows = relationship("WorkflowModel", back_populates="user")
|
|
|
|
|
selected_organization_id = Column(
|
|
|
|
|
Integer, ForeignKey("organizations.id"), nullable=True
|
|
|
|
|
)
|
|
|
|
|
selected_organization = relationship("OrganizationModel", back_populates="users")
|
|
|
|
|
organizations = relationship(
|
|
|
|
|
"OrganizationModel",
|
|
|
|
|
secondary=organization_users_association,
|
|
|
|
|
back_populates="users",
|
|
|
|
|
)
|
|
|
|
|
is_superuser = Column(Boolean, default=False)
|
2026-06-02 13:43:20 +05:30
|
|
|
email = Column(String, nullable=True)
|
2026-02-20 18:21:24 +05:30
|
|
|
password_hash = Column(String, nullable=True)
|
2025-09-09 14:37:32 +05:30
|
|
|
|
2026-06-02 13:43:20 +05:30
|
|
|
__table_args__ = (
|
|
|
|
|
Index(
|
|
|
|
|
"ix_users_email_lower",
|
|
|
|
|
func.lower(email),
|
|
|
|
|
unique=True,
|
|
|
|
|
postgresql_where=text("email IS NOT NULL"),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
2025-09-09 14:37:32 +05:30
|
|
|
|
|
|
|
|
class UserConfigurationModel(Base):
|
feat: UI refresh and user onboarding (#430)
* docs: design spec for lead-gen surfaces (Credits & Billing, Hire-an-Expert, Top-up, Enterprise)
Add brainstorming spec for: sidebar OBSERVE→MANAGE rename + Credits & Billing
link + Hire-an-Expert footer button; new /billing page with extracted Dograh
Model Credits card + CTAs; Top-up / Hire-an-Expert / Enterprise intake modals
with inline math captcha; and a workflow-builder Hire-an-Expert nudge. Frontend
only; submissions fire PostHog events via a submitLead() seam for a future
MongoDB endpoint. Also gitignore .superpowers/ brainstorm mockups.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: implementation plan for user-onboarding lead-gen surfaces
14 bite-sized tasks: PostHog events, shared helpers (field options,
work-email blocklist, submitLead seam, math captcha), three intake modals
(enterprise/hire/top-up), LeadFormsProvider context, AppLayout mount, sidebar
MANAGE rename + Credits & Billing link + footer Hire button, extracted
DograhCreditsCard, /billing page, credits removal from Agent Runs, builder
nudge, and a full verification/dogfood pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): register PostHog events for lead-gen surfaces
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): shared field options, work-email validation, and submit seam
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): inline math captcha field
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): enterprise intake modal
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): hire-an-expert modal with enterprise link
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): top-up modal with >20k volume-pricing gate
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): shared lead-forms context provider
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): mount LeadFormsProvider in app layout
Wrap the sidebar branch of AppLayout with LeadFormsProvider so the shared
lead modals are available to the sidebar, billing card, and builder nudge.
Includes eslint import-order auto-fixes in TopUpModal and LeadFormsContext.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): rename OBSERVE to MANAGE, add Credits & Billing link and Hire-an-Expert footer button
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): extract DograhCreditsCard with top-up + hire CTAs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): add Credits & Billing page
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(lead-gen): move Dograh Model Credits card out of Agent Runs to /billing
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): delayed Hire-an-Expert nudge on the workflow builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci(ui): add lint:lead-flow guard script
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(ui): restructure lead forms, self-serve Buy Credits, dialog blur
Revised lead-capture surfaces and credits bar:
- Dialog overlay gains backdrop blur (bg-black/60 backdrop-blur-sm).
- Shared primitives: LeadModalShell (icon/eyebrow header, scrollable body,
sticky footer, trust-line slot), PhoneField (react-international-phone,
dark, E.164 out), FormTrustLine ("Average response: under 10 minutes...").
- HireExpertModal: Name, Company, Job title, agent goal, Phone (required),
monthly volume. EnterpriseModal: + work email (required logged-out),
conditional deployment (yes/no/maybe, source-gated), agent goal.
OnboardingModal: drop useCase. Phone mandatory except onboarding.
- Volume buckets match the backend qualifier (0-5k/5k-100k/100k+/not-sure).
- Delete TopUpModal; DograhCreditsCard now self-serve Buy Credits (amount
chips $5/$10/$25/$50/$100 + custom min $5 → startTopUp seam) + Hire an
Expert + dashed custom-pricing link opening Enterprise (billing_custom_pricing).
- PostHog events: drop topup_*, add buy_credits_clicked,
buy_credits_amount_selected, custom_pricing_clicked. LeadFormsContext
drops topup; LeadKind/LeadSource updated.
- Introduce a single --cta warm accent token (CTAs + focus rings only).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(ui): split-screen auth + enterprise CTA + dark theme default
- AuthShell: dark two-column auth layout (brand/value panel with CSS-only
waveform motif + proof points + Bland-style enterprise CTA block on the
left, zinc-900 form card on the right; single-column on mobile).
- AuthEnterpriseCTA: "Talk to our team" → dograh.com/contact?intent=enterprise.
- stack-theme: dark StackTheme token overrides synced to globals.css.
- page.tsx: wrap StackHandler (non-fullPage) in AuthShell + StackTheme;
local-auth fallback preserved inside the shell. BackButton slimmed for the card.
- Dark locked as default: <html className="dark">, next-themes ThemeProvider
(defaultTheme="dark", enableSystem=false); inline no-FOUC script defaults dark.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ui rezig, onboarding, billing, hire us & on prem cues
* ui changes
* chore: update comment
* chore: untrack docs/superpowers and gitignore it
* feat: refactor user configuration table
* feat(ui): 'check your email' confirmation on lead forms
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* added email and country in form submissions
* chore: update leads api
* fix: wrap dograh model config in card
---------
Co-authored-by: Pritesh <pritesh@dograh.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:49:33 +05:30
|
|
|
"""Per-user keyed JSON store, mirroring organization_configurations.
|
|
|
|
|
|
|
|
|
|
Keys are defined in UserConfigurationKey. The legacy v1 AI model
|
|
|
|
|
configuration lives under MODEL_CONFIGURATION; last_validated_at is only
|
|
|
|
|
meaningful for that key.
|
|
|
|
|
"""
|
|
|
|
|
|
2025-09-09 14:37:32 +05:30
|
|
|
__tablename__ = "user_configurations"
|
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
feat: UI refresh and user onboarding (#430)
* docs: design spec for lead-gen surfaces (Credits & Billing, Hire-an-Expert, Top-up, Enterprise)
Add brainstorming spec for: sidebar OBSERVE→MANAGE rename + Credits & Billing
link + Hire-an-Expert footer button; new /billing page with extracted Dograh
Model Credits card + CTAs; Top-up / Hire-an-Expert / Enterprise intake modals
with inline math captcha; and a workflow-builder Hire-an-Expert nudge. Frontend
only; submissions fire PostHog events via a submitLead() seam for a future
MongoDB endpoint. Also gitignore .superpowers/ brainstorm mockups.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: implementation plan for user-onboarding lead-gen surfaces
14 bite-sized tasks: PostHog events, shared helpers (field options,
work-email blocklist, submitLead seam, math captcha), three intake modals
(enterprise/hire/top-up), LeadFormsProvider context, AppLayout mount, sidebar
MANAGE rename + Credits & Billing link + footer Hire button, extracted
DograhCreditsCard, /billing page, credits removal from Agent Runs, builder
nudge, and a full verification/dogfood pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): register PostHog events for lead-gen surfaces
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): shared field options, work-email validation, and submit seam
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): inline math captcha field
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): enterprise intake modal
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): hire-an-expert modal with enterprise link
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): top-up modal with >20k volume-pricing gate
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): shared lead-forms context provider
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): mount LeadFormsProvider in app layout
Wrap the sidebar branch of AppLayout with LeadFormsProvider so the shared
lead modals are available to the sidebar, billing card, and builder nudge.
Includes eslint import-order auto-fixes in TopUpModal and LeadFormsContext.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): rename OBSERVE to MANAGE, add Credits & Billing link and Hire-an-Expert footer button
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): extract DograhCreditsCard with top-up + hire CTAs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): add Credits & Billing page
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(lead-gen): move Dograh Model Credits card out of Agent Runs to /billing
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): delayed Hire-an-Expert nudge on the workflow builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci(ui): add lint:lead-flow guard script
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(ui): restructure lead forms, self-serve Buy Credits, dialog blur
Revised lead-capture surfaces and credits bar:
- Dialog overlay gains backdrop blur (bg-black/60 backdrop-blur-sm).
- Shared primitives: LeadModalShell (icon/eyebrow header, scrollable body,
sticky footer, trust-line slot), PhoneField (react-international-phone,
dark, E.164 out), FormTrustLine ("Average response: under 10 minutes...").
- HireExpertModal: Name, Company, Job title, agent goal, Phone (required),
monthly volume. EnterpriseModal: + work email (required logged-out),
conditional deployment (yes/no/maybe, source-gated), agent goal.
OnboardingModal: drop useCase. Phone mandatory except onboarding.
- Volume buckets match the backend qualifier (0-5k/5k-100k/100k+/not-sure).
- Delete TopUpModal; DograhCreditsCard now self-serve Buy Credits (amount
chips $5/$10/$25/$50/$100 + custom min $5 → startTopUp seam) + Hire an
Expert + dashed custom-pricing link opening Enterprise (billing_custom_pricing).
- PostHog events: drop topup_*, add buy_credits_clicked,
buy_credits_amount_selected, custom_pricing_clicked. LeadFormsContext
drops topup; LeadKind/LeadSource updated.
- Introduce a single --cta warm accent token (CTAs + focus rings only).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(ui): split-screen auth + enterprise CTA + dark theme default
- AuthShell: dark two-column auth layout (brand/value panel with CSS-only
waveform motif + proof points + Bland-style enterprise CTA block on the
left, zinc-900 form card on the right; single-column on mobile).
- AuthEnterpriseCTA: "Talk to our team" → dograh.com/contact?intent=enterprise.
- stack-theme: dark StackTheme token overrides synced to globals.css.
- page.tsx: wrap StackHandler (non-fullPage) in AuthShell + StackTheme;
local-auth fallback preserved inside the shell. BackButton slimmed for the card.
- Dark locked as default: <html className="dark">, next-themes ThemeProvider
(defaultTheme="dark", enableSystem=false); inline no-FOUC script defaults dark.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ui rezig, onboarding, billing, hire us & on prem cues
* ui changes
* chore: update comment
* chore: untrack docs/superpowers and gitignore it
* feat: refactor user configuration table
* feat(ui): 'check your email' confirmation on lead forms
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* added email and country in form submissions
* chore: update leads api
* fix: wrap dograh model config in card
---------
Co-authored-by: Pritesh <pritesh@dograh.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:49:33 +05:30
|
|
|
key = Column(String, nullable=False)
|
2025-09-09 14:37:32 +05:30
|
|
|
configuration = Column(JSON, nullable=False, default=dict)
|
|
|
|
|
last_validated_at = Column(DateTime(timezone=True), nullable=True)
|
|
|
|
|
|
feat: UI refresh and user onboarding (#430)
* docs: design spec for lead-gen surfaces (Credits & Billing, Hire-an-Expert, Top-up, Enterprise)
Add brainstorming spec for: sidebar OBSERVE→MANAGE rename + Credits & Billing
link + Hire-an-Expert footer button; new /billing page with extracted Dograh
Model Credits card + CTAs; Top-up / Hire-an-Expert / Enterprise intake modals
with inline math captcha; and a workflow-builder Hire-an-Expert nudge. Frontend
only; submissions fire PostHog events via a submitLead() seam for a future
MongoDB endpoint. Also gitignore .superpowers/ brainstorm mockups.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: implementation plan for user-onboarding lead-gen surfaces
14 bite-sized tasks: PostHog events, shared helpers (field options,
work-email blocklist, submitLead seam, math captcha), three intake modals
(enterprise/hire/top-up), LeadFormsProvider context, AppLayout mount, sidebar
MANAGE rename + Credits & Billing link + footer Hire button, extracted
DograhCreditsCard, /billing page, credits removal from Agent Runs, builder
nudge, and a full verification/dogfood pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): register PostHog events for lead-gen surfaces
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): shared field options, work-email validation, and submit seam
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): inline math captcha field
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): enterprise intake modal
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): hire-an-expert modal with enterprise link
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): top-up modal with >20k volume-pricing gate
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): shared lead-forms context provider
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): mount LeadFormsProvider in app layout
Wrap the sidebar branch of AppLayout with LeadFormsProvider so the shared
lead modals are available to the sidebar, billing card, and builder nudge.
Includes eslint import-order auto-fixes in TopUpModal and LeadFormsContext.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): rename OBSERVE to MANAGE, add Credits & Billing link and Hire-an-Expert footer button
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): extract DograhCreditsCard with top-up + hire CTAs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): add Credits & Billing page
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(lead-gen): move Dograh Model Credits card out of Agent Runs to /billing
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(lead-gen): delayed Hire-an-Expert nudge on the workflow builder
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci(ui): add lint:lead-flow guard script
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(ui): restructure lead forms, self-serve Buy Credits, dialog blur
Revised lead-capture surfaces and credits bar:
- Dialog overlay gains backdrop blur (bg-black/60 backdrop-blur-sm).
- Shared primitives: LeadModalShell (icon/eyebrow header, scrollable body,
sticky footer, trust-line slot), PhoneField (react-international-phone,
dark, E.164 out), FormTrustLine ("Average response: under 10 minutes...").
- HireExpertModal: Name, Company, Job title, agent goal, Phone (required),
monthly volume. EnterpriseModal: + work email (required logged-out),
conditional deployment (yes/no/maybe, source-gated), agent goal.
OnboardingModal: drop useCase. Phone mandatory except onboarding.
- Volume buckets match the backend qualifier (0-5k/5k-100k/100k+/not-sure).
- Delete TopUpModal; DograhCreditsCard now self-serve Buy Credits (amount
chips $5/$10/$25/$50/$100 + custom min $5 → startTopUp seam) + Hire an
Expert + dashed custom-pricing link opening Enterprise (billing_custom_pricing).
- PostHog events: drop topup_*, add buy_credits_clicked,
buy_credits_amount_selected, custom_pricing_clicked. LeadFormsContext
drops topup; LeadKind/LeadSource updated.
- Introduce a single --cta warm accent token (CTAs + focus rings only).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(ui): split-screen auth + enterprise CTA + dark theme default
- AuthShell: dark two-column auth layout (brand/value panel with CSS-only
waveform motif + proof points + Bland-style enterprise CTA block on the
left, zinc-900 form card on the right; single-column on mobile).
- AuthEnterpriseCTA: "Talk to our team" → dograh.com/contact?intent=enterprise.
- stack-theme: dark StackTheme token overrides synced to globals.css.
- page.tsx: wrap StackHandler (non-fullPage) in AuthShell + StackTheme;
local-auth fallback preserved inside the shell. BackButton slimmed for the card.
- Dark locked as default: <html className="dark">, next-themes ThemeProvider
(defaultTheme="dark", enableSystem=false); inline no-FOUC script defaults dark.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ui rezig, onboarding, billing, hire us & on prem cues
* ui changes
* chore: update comment
* chore: untrack docs/superpowers and gitignore it
* feat: refactor user configuration table
* feat(ui): 'check your email' confirmation on lead forms
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* added email and country in form submissions
* chore: update leads api
* fix: wrap dograh model config in card
---------
Co-authored-by: Pritesh <pritesh@dograh.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:49:33 +05:30
|
|
|
__table_args__ = (
|
|
|
|
|
UniqueConstraint("user_id", "key", name="_user_configuration_key_uc"),
|
|
|
|
|
)
|
|
|
|
|
|
2025-09-09 14:37:32 +05:30
|
|
|
|
|
|
|
|
# New Organization model
|
|
|
|
|
class OrganizationModel(Base):
|
|
|
|
|
__tablename__ = "organizations"
|
|
|
|
|
|
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
|
provider_id = Column(String, unique=True, index=True, nullable=False)
|
|
|
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
|
|
|
|
2026-06-12 14:55:30 +05:30
|
|
|
# Deprecated: MPS owns quota and credit ledger state.
|
2025-09-09 14:37:32 +05:30
|
|
|
quota_type = Column(
|
|
|
|
|
Enum("monthly", "annual", name="quota_type"),
|
|
|
|
|
nullable=False,
|
|
|
|
|
default="monthly",
|
|
|
|
|
server_default=text("'monthly'::quota_type"),
|
2026-06-12 14:55:30 +05:30
|
|
|
comment="Deprecated. MPS owns quota and credit ledger state.",
|
|
|
|
|
info={"deprecated": True},
|
2025-09-09 14:37:32 +05:30
|
|
|
)
|
|
|
|
|
quota_dograh_tokens = Column(
|
2026-06-12 14:55:30 +05:30
|
|
|
Integer,
|
|
|
|
|
nullable=False,
|
|
|
|
|
default=0,
|
|
|
|
|
server_default=text("0"),
|
|
|
|
|
comment="Deprecated. MPS owns quota and credit ledger state.",
|
|
|
|
|
info={"deprecated": True},
|
2025-09-09 14:37:32 +05:30
|
|
|
)
|
|
|
|
|
quota_reset_day = Column(
|
2026-06-12 14:55:30 +05:30
|
|
|
Integer,
|
|
|
|
|
nullable=False,
|
|
|
|
|
default=1,
|
|
|
|
|
server_default=text("1"),
|
|
|
|
|
comment="Deprecated. MPS owns quota and credit ledger state.",
|
|
|
|
|
info={"deprecated": True},
|
|
|
|
|
)
|
|
|
|
|
quota_start_date = Column(
|
|
|
|
|
DateTime(timezone=True),
|
|
|
|
|
nullable=True,
|
|
|
|
|
comment="Deprecated. MPS owns quota and credit ledger state.",
|
|
|
|
|
info={"deprecated": True},
|
|
|
|
|
)
|
2025-09-09 14:37:32 +05:30
|
|
|
quota_enabled = Column(
|
2026-06-12 14:55:30 +05:30
|
|
|
Boolean,
|
|
|
|
|
nullable=False,
|
|
|
|
|
default=False,
|
|
|
|
|
server_default=text("false"),
|
|
|
|
|
comment="Deprecated. MPS owns quota and credit ledger state.",
|
|
|
|
|
info={"deprecated": True},
|
2025-09-09 14:37:32 +05:30
|
|
|
)
|
|
|
|
|
|
|
|
|
|
price_per_second_usd = Column(Float, nullable=True)
|
|
|
|
|
|
|
|
|
|
# Relationships
|
|
|
|
|
users = relationship(
|
|
|
|
|
"UserModel",
|
|
|
|
|
secondary=organization_users_association,
|
|
|
|
|
back_populates="organizations",
|
|
|
|
|
)
|
|
|
|
|
integrations = relationship("IntegrationModel", back_populates="organization")
|
|
|
|
|
usage_cycles = relationship(
|
|
|
|
|
"OrganizationUsageCycleModel", back_populates="organization"
|
|
|
|
|
)
|
|
|
|
|
configurations = relationship(
|
|
|
|
|
"OrganizationConfigurationModel", back_populates="organization"
|
|
|
|
|
)
|
|
|
|
|
api_keys = relationship("APIKeyModel", back_populates="organization")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class APIKeyModel(Base):
|
|
|
|
|
__tablename__ = "api_keys"
|
|
|
|
|
|
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
|
organization_id = Column(
|
|
|
|
|
Integer, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
|
|
|
|
|
)
|
|
|
|
|
name = Column(String, nullable=False)
|
|
|
|
|
key_hash = Column(String, nullable=False, unique=True, index=True)
|
|
|
|
|
key_prefix = Column(String, nullable=False) # Store first 8 chars for display
|
|
|
|
|
is_active = Column(Boolean, default=True, nullable=False)
|
|
|
|
|
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
|
|
|
|
last_used_at = Column(DateTime(timezone=True), nullable=True)
|
|
|
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
|
|
|
archived_at = Column(DateTime(timezone=True), nullable=True)
|
|
|
|
|
|
|
|
|
|
# Relationships
|
|
|
|
|
organization = relationship("OrganizationModel", back_populates="api_keys")
|
|
|
|
|
created_by_user = relationship("UserModel")
|
|
|
|
|
|
|
|
|
|
# Indexes for performance
|
|
|
|
|
__table_args__ = (
|
|
|
|
|
Index("ix_api_keys_organization_id", "organization_id"),
|
|
|
|
|
Index("ix_api_keys_key_hash", "key_hash"),
|
|
|
|
|
Index("ix_api_keys_active", "is_active"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class OrganizationConfigurationModel(Base):
|
|
|
|
|
__tablename__ = "organization_configurations"
|
|
|
|
|
|
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
|
organization_id = Column(
|
|
|
|
|
Integer, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
|
|
|
|
|
)
|
|
|
|
|
key = Column(String, nullable=False)
|
|
|
|
|
value = Column(JSON, nullable=False, default=dict)
|
|
|
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
|
|
|
updated_at = Column(
|
|
|
|
|
DateTime(timezone=True),
|
|
|
|
|
default=lambda: datetime.now(UTC),
|
|
|
|
|
onupdate=lambda: datetime.now(UTC),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Relationships
|
|
|
|
|
organization = relationship("OrganizationModel", back_populates="configurations")
|
|
|
|
|
|
|
|
|
|
# Constraints and indexes
|
|
|
|
|
__table_args__ = (
|
|
|
|
|
UniqueConstraint("organization_id", "key", name="_organization_key_uc"),
|
|
|
|
|
Index("ix_organization_configurations_organization_id", "organization_id"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-04-29 11:39:57 +05:30
|
|
|
class TelephonyConfigurationModel(Base):
|
|
|
|
|
__tablename__ = "telephony_configurations"
|
|
|
|
|
|
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
|
organization_id = Column(
|
|
|
|
|
Integer, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
|
|
|
|
|
)
|
|
|
|
|
name = Column(String(64), nullable=False)
|
|
|
|
|
provider = Column(String(32), nullable=False)
|
|
|
|
|
credentials = Column(JSON, nullable=False, default=dict)
|
|
|
|
|
is_default_outbound = Column(
|
|
|
|
|
Boolean, nullable=False, default=False, server_default=text("false")
|
|
|
|
|
)
|
|
|
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
|
|
|
updated_at = Column(
|
|
|
|
|
DateTime(timezone=True),
|
|
|
|
|
default=lambda: datetime.now(UTC),
|
|
|
|
|
onupdate=lambda: datetime.now(UTC),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
organization = relationship("OrganizationModel")
|
|
|
|
|
phone_numbers = relationship(
|
|
|
|
|
"TelephonyPhoneNumberModel",
|
|
|
|
|
back_populates="configuration",
|
|
|
|
|
cascade="all, delete-orphan",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
__table_args__ = (
|
|
|
|
|
UniqueConstraint(
|
|
|
|
|
"organization_id", "name", name="uq_telephony_configurations_org_name"
|
|
|
|
|
),
|
|
|
|
|
Index("ix_telephony_configurations_org", "organization_id"),
|
|
|
|
|
Index(
|
|
|
|
|
"uq_telephony_configurations_default",
|
|
|
|
|
"organization_id",
|
|
|
|
|
unique=True,
|
|
|
|
|
postgresql_where=text("is_default_outbound = true"),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TelephonyPhoneNumberModel(Base):
|
|
|
|
|
__tablename__ = "telephony_phone_numbers"
|
|
|
|
|
|
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
|
organization_id = Column(
|
|
|
|
|
Integer, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
|
|
|
|
|
)
|
|
|
|
|
telephony_configuration_id = Column(
|
|
|
|
|
Integer,
|
|
|
|
|
ForeignKey("telephony_configurations.id", ondelete="CASCADE"),
|
|
|
|
|
nullable=False,
|
|
|
|
|
)
|
|
|
|
|
address = Column(String(255), nullable=False)
|
|
|
|
|
address_normalized = Column(String(255), nullable=False)
|
|
|
|
|
address_type = Column(String(16), nullable=False)
|
|
|
|
|
country_code = Column(String(2), nullable=True)
|
|
|
|
|
label = Column(String(64), nullable=True)
|
|
|
|
|
inbound_workflow_id = Column(
|
|
|
|
|
Integer,
|
|
|
|
|
ForeignKey("workflows.id", ondelete="SET NULL"),
|
|
|
|
|
nullable=True,
|
|
|
|
|
)
|
|
|
|
|
is_active = Column(
|
|
|
|
|
Boolean, nullable=False, default=True, server_default=text("true")
|
|
|
|
|
)
|
|
|
|
|
is_default_caller_id = Column(
|
|
|
|
|
Boolean, nullable=False, default=False, server_default=text("false")
|
|
|
|
|
)
|
|
|
|
|
extra_metadata = Column(
|
|
|
|
|
JSON, nullable=False, default=dict, server_default=text("'{}'::json")
|
|
|
|
|
)
|
|
|
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
|
|
|
updated_at = Column(
|
|
|
|
|
DateTime(timezone=True),
|
|
|
|
|
default=lambda: datetime.now(UTC),
|
|
|
|
|
onupdate=lambda: datetime.now(UTC),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
configuration = relationship(
|
|
|
|
|
"TelephonyConfigurationModel", back_populates="phone_numbers"
|
|
|
|
|
)
|
|
|
|
|
inbound_workflow = relationship("WorkflowModel")
|
|
|
|
|
|
|
|
|
|
__table_args__ = (
|
|
|
|
|
UniqueConstraint(
|
|
|
|
|
"organization_id",
|
|
|
|
|
"address_normalized",
|
|
|
|
|
name="uq_phone_numbers_org_address",
|
|
|
|
|
),
|
|
|
|
|
Index("ix_phone_numbers_config", "telephony_configuration_id"),
|
|
|
|
|
Index(
|
|
|
|
|
"ix_phone_numbers_workflow",
|
|
|
|
|
"inbound_workflow_id",
|
|
|
|
|
postgresql_where=text("inbound_workflow_id IS NOT NULL"),
|
|
|
|
|
),
|
|
|
|
|
Index(
|
|
|
|
|
"ix_phone_numbers_inbound_lookup",
|
|
|
|
|
"address_normalized",
|
|
|
|
|
"organization_id",
|
|
|
|
|
postgresql_where=text("is_active = true"),
|
|
|
|
|
),
|
|
|
|
|
Index(
|
|
|
|
|
"uq_phone_numbers_default_caller",
|
|
|
|
|
"telephony_configuration_id",
|
|
|
|
|
unique=True,
|
|
|
|
|
postgresql_where=text("is_default_caller_id = true"),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2025-09-09 14:37:32 +05:30
|
|
|
class IntegrationModel(Base):
|
|
|
|
|
__tablename__ = "integrations"
|
|
|
|
|
|
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
2026-05-20 10:07:33 +01:00
|
|
|
integration_id = Column(
|
|
|
|
|
String, nullable=False, index=True
|
|
|
|
|
) # External connection ID
|
2026-05-19 17:02:26 +05:30
|
|
|
organization_id = Column(Integer, ForeignKey("organizations.id"), nullable=False)
|
2025-09-09 14:37:32 +05:30
|
|
|
provider = Column(String, nullable=False)
|
|
|
|
|
created_by = Column(Integer, ForeignKey("users.id"))
|
|
|
|
|
is_active = Column(Boolean, default=True, nullable=False)
|
|
|
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
|
|
|
connection_details = Column(JSON, nullable=False, default=dict)
|
|
|
|
|
action = Column(String, nullable=False, default=IntegrationAction.ALL_CALLS.value)
|
|
|
|
|
|
|
|
|
|
# Relationships
|
|
|
|
|
organization = relationship("OrganizationModel", back_populates="integrations")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class WorkflowDefinitionModel(Base):
|
|
|
|
|
__tablename__ = "workflow_definitions"
|
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
2026-04-08 19:20:31 +05:30
|
|
|
workflow_hash = Column(String, nullable=True) # Legacy, no longer used
|
2025-09-09 14:37:32 +05:30
|
|
|
workflow_json = Column(JSON, nullable=False, default=dict)
|
|
|
|
|
workflow_id = Column(Integer, ForeignKey("workflows.id"), nullable=True)
|
|
|
|
|
is_current = Column(
|
|
|
|
|
Boolean, default=False, nullable=False, server_default=text("false")
|
|
|
|
|
)
|
|
|
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
|
|
|
|
2026-04-08 19:20:31 +05:30
|
|
|
# Versioning columns
|
|
|
|
|
status = Column(
|
|
|
|
|
String,
|
|
|
|
|
nullable=False,
|
|
|
|
|
default="published",
|
|
|
|
|
server_default=text("'published'"),
|
|
|
|
|
) # draft | published | archived
|
|
|
|
|
version_number = Column(
|
|
|
|
|
Integer, nullable=True
|
|
|
|
|
) # Sequential per workflow, display only
|
|
|
|
|
published_at = Column(DateTime(timezone=True), nullable=True)
|
|
|
|
|
|
|
|
|
|
# Full behavioral snapshot (moved from WorkflowModel to enable versioning)
|
|
|
|
|
workflow_configurations = Column(
|
|
|
|
|
JSON, nullable=False, default=dict, server_default=text("'{}'::json")
|
|
|
|
|
)
|
|
|
|
|
template_context_variables = Column(
|
|
|
|
|
JSON, nullable=False, default=dict, server_default=text("'{}'::json")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Table constraints and indexes — unique hash constraint removed (no more dedup)
|
2025-09-09 14:37:32 +05:30
|
|
|
__table_args__ = (
|
2026-04-08 19:20:31 +05:30
|
|
|
Index("ix_workflow_definitions_workflow_status", "workflow_id", "status"),
|
2025-09-09 14:37:32 +05:30
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Relationships
|
|
|
|
|
workflow = relationship(
|
|
|
|
|
"WorkflowModel",
|
|
|
|
|
back_populates="definitions",
|
|
|
|
|
foreign_keys=[workflow_id],
|
|
|
|
|
)
|
|
|
|
|
workflow_runs = relationship("WorkflowRunModel", back_populates="definition")
|
|
|
|
|
|
|
|
|
|
|
2026-05-22 14:36:50 +05:30
|
|
|
class FolderModel(Base):
|
|
|
|
|
"""A folder for grouping workflows (agents) within an organization.
|
|
|
|
|
|
|
|
|
|
Folders are flat (no nesting) and org-scoped. A workflow belongs to at
|
|
|
|
|
most one folder via ``WorkflowModel.folder_id``; a NULL folder_id means
|
|
|
|
|
the workflow is "Uncategorized".
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
__tablename__ = "folders"
|
|
|
|
|
|
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
|
organization_id = Column(
|
|
|
|
|
Integer, ForeignKey("organizations.id"), nullable=False, index=True
|
|
|
|
|
)
|
|
|
|
|
organization = relationship("OrganizationModel")
|
|
|
|
|
name = Column(String, nullable=False)
|
|
|
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
|
|
|
|
|
|
|
|
workflows = relationship("WorkflowModel", back_populates="folder")
|
|
|
|
|
|
|
|
|
|
# Folder names must be unique within an organization.
|
|
|
|
|
__table_args__ = (
|
|
|
|
|
UniqueConstraint("organization_id", "name", name="uq_folder_org_name"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2025-09-09 14:37:32 +05:30
|
|
|
class WorkflowModel(Base):
|
|
|
|
|
__tablename__ = "workflows"
|
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
2026-04-25 19:44:44 +05:30
|
|
|
workflow_uuid = Column(
|
|
|
|
|
String(36),
|
|
|
|
|
unique=True,
|
|
|
|
|
nullable=False,
|
|
|
|
|
index=True,
|
|
|
|
|
default=lambda: str(uuid.uuid4()),
|
|
|
|
|
)
|
2025-09-09 14:37:32 +05:30
|
|
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
|
|
|
|
user = relationship("UserModel", back_populates="workflows")
|
|
|
|
|
organization_id = Column(Integer, ForeignKey("organizations.id"), nullable=True)
|
|
|
|
|
organization = relationship("OrganizationModel")
|
2026-05-22 14:36:50 +05:30
|
|
|
# Optional folder for grouping in the agents list. NULL = "Uncategorized".
|
|
|
|
|
# ON DELETE SET NULL: deleting a folder un-files its agents, never deletes them.
|
|
|
|
|
folder_id = Column(
|
|
|
|
|
Integer,
|
|
|
|
|
ForeignKey("folders.id", ondelete="SET NULL"),
|
|
|
|
|
nullable=True,
|
|
|
|
|
index=True,
|
|
|
|
|
)
|
|
|
|
|
folder = relationship("FolderModel", back_populates="workflows")
|
2025-09-09 14:37:32 +05:30
|
|
|
name = Column(String, index=True, nullable=False)
|
|
|
|
|
status = Column(
|
|
|
|
|
Enum(*[status.value for status in WorkflowStatus], name="workflow_status"),
|
|
|
|
|
nullable=False,
|
|
|
|
|
default=WorkflowStatus.ACTIVE.value,
|
|
|
|
|
server_default=text("'active'::workflow_status"),
|
|
|
|
|
)
|
|
|
|
|
workflow_definition = Column(JSON, nullable=False, default=dict)
|
|
|
|
|
template_context_variables = Column(JSON, nullable=False, default=dict)
|
|
|
|
|
call_disposition_codes = Column(JSON, nullable=False, default=dict)
|
|
|
|
|
workflow_configurations = Column(
|
|
|
|
|
JSON, nullable=False, default=dict, server_default=text("'{}'::json")
|
|
|
|
|
)
|
|
|
|
|
runs = relationship("WorkflowRunModel", back_populates="workflow")
|
|
|
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
|
|
|
|
2026-04-08 19:20:31 +05:30
|
|
|
# Pointer to the currently-live (published) version
|
|
|
|
|
released_definition_id = Column(
|
|
|
|
|
Integer,
|
|
|
|
|
ForeignKey("workflow_definitions.id", use_alter=True),
|
|
|
|
|
nullable=True,
|
|
|
|
|
)
|
|
|
|
|
released_definition = relationship(
|
|
|
|
|
"WorkflowDefinitionModel",
|
|
|
|
|
foreign_keys=[released_definition_id],
|
|
|
|
|
uselist=False,
|
|
|
|
|
viewonly=True,
|
|
|
|
|
)
|
|
|
|
|
|
2025-09-09 14:37:32 +05:30
|
|
|
# All versions / historical definitions of this workflow
|
|
|
|
|
definitions = relationship(
|
|
|
|
|
"WorkflowDefinitionModel",
|
|
|
|
|
back_populates="workflow",
|
|
|
|
|
foreign_keys="WorkflowDefinitionModel.workflow_id",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Relationship to fetch the current (is_current=True) definition
|
2026-04-08 19:20:31 +05:30
|
|
|
# Kept for backward compatibility during transition
|
2025-09-09 14:37:32 +05:30
|
|
|
current_definition = relationship(
|
|
|
|
|
"WorkflowDefinitionModel",
|
|
|
|
|
primaryjoin=lambda: and_(
|
|
|
|
|
WorkflowDefinitionModel.workflow_id == WorkflowModel.id,
|
|
|
|
|
WorkflowDefinitionModel.is_current.is_(True),
|
|
|
|
|
),
|
|
|
|
|
uselist=False,
|
|
|
|
|
viewonly=True,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def current_definition_id(self):
|
|
|
|
|
"""Return ID of the current workflow definition (helper for backwards-compat)."""
|
|
|
|
|
current_def = self.__dict__.get("current_definition")
|
|
|
|
|
if current_def is not None:
|
|
|
|
|
return current_def.id
|
|
|
|
|
|
|
|
|
|
# If relationship is not loaded, we cannot safely access definitions without
|
|
|
|
|
# risking an implicit lazy load on a detached instance. Return ``None`` in
|
|
|
|
|
# that scenario so callers can handle the absence explicitly.
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class WorkflowTemplates(Base):
|
|
|
|
|
__tablename__ = "workflow_templates"
|
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
|
template_name = Column(String, nullable=False, index=True)
|
|
|
|
|
template_description = Column(String, nullable=False, index=True)
|
|
|
|
|
template_json = Column(JSON, nullable=False, default=dict)
|
|
|
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class WorkflowRunModel(Base):
|
|
|
|
|
__tablename__ = "workflow_runs"
|
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
|
name = Column(String, nullable=False)
|
|
|
|
|
workflow_id = Column(Integer, ForeignKey("workflows.id"), nullable=False)
|
|
|
|
|
workflow = relationship("WorkflowModel", back_populates="runs")
|
|
|
|
|
definition_id = Column(
|
|
|
|
|
Integer, ForeignKey("workflow_definitions.id"), nullable=True
|
|
|
|
|
)
|
|
|
|
|
definition = relationship("WorkflowDefinitionModel", back_populates="workflow_runs")
|
2026-04-29 11:39:57 +05:30
|
|
|
# Stored as VARCHAR (not a Postgres ENUM) so new telephony providers can
|
|
|
|
|
# be added purely in application code without a database migration.
|
|
|
|
|
# See WorkflowRunMode in api/enums.py for the canonical value set.
|
|
|
|
|
mode = Column(String(64), nullable=False)
|
2026-01-12 10:10:30 +05:30
|
|
|
call_type = Column(
|
|
|
|
|
Enum(*[call_type.value for call_type in CallType], name="workflow_call_type"),
|
|
|
|
|
nullable=False,
|
|
|
|
|
default=CallType.OUTBOUND.value,
|
|
|
|
|
server_default=text("'outbound'::workflow_call_type"),
|
|
|
|
|
)
|
2025-12-11 15:42:28 +05:30
|
|
|
state = Column(
|
|
|
|
|
Enum(*[state.value for state in WorkflowRunState], name="workflow_run_state"),
|
|
|
|
|
nullable=False,
|
|
|
|
|
default=WorkflowRunState.INITIALIZED.value,
|
|
|
|
|
server_default=text("'initialized'::workflow_run_state"),
|
|
|
|
|
)
|
2025-09-09 14:37:32 +05:30
|
|
|
is_completed = Column(Boolean, default=False)
|
|
|
|
|
recording_url = Column(String, nullable=True)
|
|
|
|
|
transcript_url = Column(String, nullable=True)
|
2026-06-16 15:19:49 +05:30
|
|
|
extra = Column(
|
|
|
|
|
JSON, nullable=False, default=dict, server_default=text("'{}'::json")
|
|
|
|
|
)
|
2025-09-09 14:37:32 +05:30
|
|
|
# Store storage backend as string enum (s3, minio)
|
|
|
|
|
storage_backend = Column(
|
|
|
|
|
Enum("s3", "minio", name="storage_backend"),
|
|
|
|
|
nullable=False,
|
|
|
|
|
default="s3",
|
|
|
|
|
server_default=text("'s3'::storage_backend"),
|
|
|
|
|
)
|
|
|
|
|
usage_info = Column(JSON, nullable=False, default=dict)
|
|
|
|
|
cost_info = Column(JSON, nullable=False, default=dict)
|
|
|
|
|
initial_context = Column(JSON, nullable=False, default=dict)
|
|
|
|
|
gathered_context = Column(JSON, nullable=False, default=dict)
|
|
|
|
|
logs = Column(JSON, nullable=False, default=dict, server_default=text("'{}'::json"))
|
|
|
|
|
annotations = Column(JSON, nullable=False, default=dict)
|
|
|
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
|
|
|
campaign_id = Column(Integer, ForeignKey("campaigns.id"), nullable=True)
|
|
|
|
|
campaign = relationship("CampaignModel")
|
|
|
|
|
queued_run_id = Column(Integer, ForeignKey("queued_runs.id"), nullable=True)
|
|
|
|
|
queued_run = relationship("QueuedRunModel", foreign_keys=[queued_run_id])
|
2026-01-23 18:53:59 +05:30
|
|
|
public_access_token = Column(String(36), nullable=True)
|
2026-05-21 15:20:02 +05:30
|
|
|
text_session = relationship(
|
|
|
|
|
"WorkflowRunTextSessionModel",
|
|
|
|
|
back_populates="workflow_run",
|
|
|
|
|
uselist=False,
|
|
|
|
|
cascade="all, delete-orphan",
|
|
|
|
|
)
|
2026-01-23 18:53:59 +05:30
|
|
|
|
|
|
|
|
# Indexes
|
|
|
|
|
__table_args__ = (
|
|
|
|
|
Index(
|
|
|
|
|
"idx_workflow_runs_public_access_token",
|
|
|
|
|
"public_access_token",
|
|
|
|
|
unique=True,
|
|
|
|
|
postgresql_where=text("public_access_token IS NOT NULL"),
|
|
|
|
|
),
|
2026-01-29 19:06:52 +05:30
|
|
|
Index(
|
|
|
|
|
"idx_workflow_runs_call_id",
|
|
|
|
|
text("(gathered_context->>'call_id')"),
|
|
|
|
|
postgresql_where=text("gathered_context->>'call_id' IS NOT NULL"),
|
|
|
|
|
),
|
2026-01-29 20:43:53 +05:30
|
|
|
Index("idx_workflow_runs_workflow_id", "workflow_id"),
|
|
|
|
|
Index("idx_workflow_runs_campaign_id", "campaign_id"),
|
2026-01-23 18:53:59 +05:30
|
|
|
)
|
2025-09-09 14:37:32 +05:30
|
|
|
|
|
|
|
|
|
2026-05-21 15:20:02 +05:30
|
|
|
class WorkflowRunTextSessionModel(Base):
|
|
|
|
|
__tablename__ = "workflow_run_text_sessions"
|
|
|
|
|
|
|
|
|
|
workflow_run_id = Column(
|
|
|
|
|
Integer,
|
|
|
|
|
ForeignKey("workflow_runs.id", ondelete="CASCADE"),
|
|
|
|
|
primary_key=True,
|
|
|
|
|
)
|
|
|
|
|
workflow_run = relationship("WorkflowRunModel", back_populates="text_session")
|
|
|
|
|
revision = Column(
|
|
|
|
|
Integer,
|
|
|
|
|
nullable=False,
|
|
|
|
|
default=0,
|
|
|
|
|
server_default=text("0"),
|
|
|
|
|
)
|
|
|
|
|
session_data = Column(
|
|
|
|
|
JSON,
|
|
|
|
|
nullable=False,
|
|
|
|
|
default=dict,
|
|
|
|
|
server_default=text("'{}'::json"),
|
|
|
|
|
)
|
|
|
|
|
checkpoint = Column(
|
|
|
|
|
JSON,
|
|
|
|
|
nullable=False,
|
|
|
|
|
default=dict,
|
|
|
|
|
server_default=text("'{}'::json"),
|
|
|
|
|
)
|
|
|
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
|
|
|
updated_at = Column(
|
|
|
|
|
DateTime(timezone=True),
|
|
|
|
|
default=lambda: datetime.now(UTC),
|
|
|
|
|
onupdate=lambda: datetime.now(UTC),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
__table_args__ = (Index("ix_workflow_run_text_sessions_updated_at", "updated_at"),)
|
|
|
|
|
|
|
|
|
|
|
2025-09-09 14:37:32 +05:30
|
|
|
class OrganizationUsageCycleModel(Base):
|
|
|
|
|
"""
|
2026-06-12 14:55:30 +05:30
|
|
|
This model is used to track reporting aggregates for an organization for a given
|
|
|
|
|
usage cycle. Quota fields on this model are deprecated; MPS owns quota and
|
|
|
|
|
credit ledger state.
|
2025-09-09 14:37:32 +05:30
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
__tablename__ = "organization_usage_cycles"
|
|
|
|
|
|
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
|
organization_id = Column(Integer, ForeignKey("organizations.id"), nullable=False)
|
|
|
|
|
period_start = Column(DateTime(timezone=True), nullable=False)
|
|
|
|
|
period_end = Column(DateTime(timezone=True), nullable=False)
|
2026-06-12 14:55:30 +05:30
|
|
|
quota_dograh_tokens = Column(
|
|
|
|
|
Integer,
|
|
|
|
|
nullable=False,
|
|
|
|
|
comment="Deprecated. MPS owns quota and credit ledger state.",
|
|
|
|
|
info={"deprecated": True},
|
|
|
|
|
)
|
2025-09-09 14:37:32 +05:30
|
|
|
used_dograh_tokens = Column(Float, nullable=False, default=0)
|
|
|
|
|
total_duration_seconds = Column(
|
|
|
|
|
Integer, nullable=False, default=0, server_default=text("0")
|
|
|
|
|
)
|
|
|
|
|
# New USD tracking fields
|
|
|
|
|
used_amount_usd = Column(Float, nullable=True, default=0)
|
2026-06-12 14:55:30 +05:30
|
|
|
quota_amount_usd = Column(
|
|
|
|
|
Float,
|
|
|
|
|
nullable=True,
|
|
|
|
|
comment="Deprecated. MPS owns quota and credit ledger state.",
|
|
|
|
|
info={"deprecated": True},
|
|
|
|
|
)
|
2025-09-09 14:37:32 +05:30
|
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
|
|
|
updated_at = Column(
|
|
|
|
|
DateTime(timezone=True),
|
|
|
|
|
default=lambda: datetime.now(UTC),
|
|
|
|
|
onupdate=lambda: datetime.now(UTC),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Relationships
|
|
|
|
|
organization = relationship("OrganizationModel", back_populates="usage_cycles")
|
|
|
|
|
|
|
|
|
|
# Constraints and indexes
|
|
|
|
|
__table_args__ = (
|
|
|
|
|
UniqueConstraint(
|
|
|
|
|
"organization_id", "period_start", "period_end", name="unique_org_period"
|
|
|
|
|
),
|
|
|
|
|
Index("idx_usage_cycles_org_period", "organization_id", "period_end"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class CampaignModel(Base):
|
|
|
|
|
__tablename__ = "campaigns"
|
|
|
|
|
|
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
|
name = Column(String, nullable=False, index=True)
|
|
|
|
|
organization_id = Column(Integer, ForeignKey("organizations.id"), nullable=False)
|
|
|
|
|
workflow_id = Column(Integer, ForeignKey("workflows.id"), nullable=False)
|
|
|
|
|
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
2026-04-29 11:39:57 +05:30
|
|
|
# Nullable during the legacy → multi-config migration window. Backfilled to the
|
|
|
|
|
# org's default config by the migration; will become NOT NULL in a follow-up.
|
|
|
|
|
telephony_configuration_id = Column(
|
|
|
|
|
Integer, ForeignKey("telephony_configurations.id"), nullable=True
|
|
|
|
|
)
|
2025-09-09 14:37:32 +05:30
|
|
|
|
|
|
|
|
# Source configuration
|
2026-05-20 10:07:33 +01:00
|
|
|
source_type = Column(String, nullable=False, default="csv")
|
|
|
|
|
source_id = Column(String, nullable=False) # CSV file key
|
2025-09-09 14:37:32 +05:30
|
|
|
|
|
|
|
|
# State management
|
|
|
|
|
state = Column(
|
|
|
|
|
Enum(
|
|
|
|
|
"created",
|
|
|
|
|
"syncing",
|
|
|
|
|
"running",
|
|
|
|
|
"paused",
|
|
|
|
|
"completed",
|
|
|
|
|
"failed",
|
|
|
|
|
name="campaign_state",
|
|
|
|
|
),
|
|
|
|
|
nullable=False,
|
|
|
|
|
default="created",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Progress tracking
|
|
|
|
|
total_rows = Column(Integer, nullable=True)
|
|
|
|
|
processed_rows = Column(Integer, nullable=False, default=0)
|
|
|
|
|
failed_rows = Column(Integer, nullable=False, default=0)
|
|
|
|
|
|
|
|
|
|
# Rate limiting and sync configuration
|
|
|
|
|
rate_limit_per_second = Column(Integer, nullable=False, default=1)
|
|
|
|
|
max_retries = Column(Integer, nullable=False, default=0)
|
|
|
|
|
source_sync_status = Column(String, nullable=False, default="pending")
|
|
|
|
|
source_last_synced_at = Column(DateTime(timezone=True), nullable=True)
|
|
|
|
|
source_sync_error = Column(String, nullable=True)
|
|
|
|
|
|
|
|
|
|
# Retry configuration for call failures
|
|
|
|
|
retry_config = Column(
|
|
|
|
|
JSON,
|
|
|
|
|
nullable=False,
|
2026-01-29 11:57:57 +05:30
|
|
|
default=DEFAULT_CAMPAIGN_RETRY_CONFIG,
|
2025-09-09 14:37:32 +05:30
|
|
|
server_default=text(
|
|
|
|
|
'\'{"enabled": true, "max_retries": 2, "retry_on_busy": true, "retry_on_no_answer": true, "retry_on_voicemail": true, "retry_delay_seconds": 120}\'::jsonb'
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Orchestrator tracking fields
|
|
|
|
|
last_batch_scheduled_at = Column(DateTime(timezone=True), nullable=True)
|
|
|
|
|
last_activity_at = Column(DateTime(timezone=True), nullable=True)
|
|
|
|
|
orchestrator_metadata = Column(
|
|
|
|
|
JSON, nullable=False, default=dict, server_default=text("'{}'::json")
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-05 19:23:50 +05:30
|
|
|
# Append-only timestamped log entries for state transitions, failures,
|
|
|
|
|
# and circuit-breaker events. Surfaced in the UI so operators can see
|
|
|
|
|
# why a campaign moved to paused/failed without digging through logs.
|
|
|
|
|
logs = Column(
|
|
|
|
|
JSON,
|
|
|
|
|
nullable=False,
|
|
|
|
|
default=list,
|
|
|
|
|
server_default=text("'[]'::json"),
|
|
|
|
|
)
|
|
|
|
|
|
2025-09-09 14:37:32 +05:30
|
|
|
# Timestamps
|
|
|
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
|
|
|
started_at = Column(DateTime(timezone=True), nullable=True)
|
|
|
|
|
completed_at = Column(DateTime(timezone=True), nullable=True)
|
|
|
|
|
updated_at = Column(
|
|
|
|
|
DateTime(timezone=True),
|
|
|
|
|
default=lambda: datetime.now(UTC),
|
|
|
|
|
onupdate=lambda: datetime.now(UTC),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Relationships
|
|
|
|
|
organization = relationship("OrganizationModel")
|
|
|
|
|
workflow = relationship("WorkflowModel")
|
|
|
|
|
created_by_user = relationship("UserModel")
|
|
|
|
|
|
|
|
|
|
# Indexes
|
|
|
|
|
__table_args__ = (
|
|
|
|
|
Index("ix_campaigns_org_id", "organization_id"),
|
|
|
|
|
Index("ix_campaigns_state", "state"),
|
|
|
|
|
Index("ix_campaigns_workflow_id", "workflow_id"),
|
2026-04-29 11:39:57 +05:30
|
|
|
Index(
|
|
|
|
|
"ix_campaigns_telephony_config",
|
|
|
|
|
"telephony_configuration_id",
|
|
|
|
|
postgresql_where=text("telephony_configuration_id IS NOT NULL"),
|
|
|
|
|
),
|
2025-09-09 14:37:32 +05:30
|
|
|
# Index for efficient querying of active campaigns
|
|
|
|
|
Index(
|
|
|
|
|
"idx_campaigns_active_status",
|
|
|
|
|
"state",
|
|
|
|
|
postgresql_where=text("state IN ('syncing', 'running', 'paused')"),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class QueuedRunModel(Base):
|
|
|
|
|
__tablename__ = "queued_runs"
|
|
|
|
|
|
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
|
campaign_id = Column(
|
|
|
|
|
Integer, ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False
|
|
|
|
|
)
|
|
|
|
|
source_uuid = Column(String, nullable=False)
|
|
|
|
|
context_variables = Column(JSON, nullable=False, default=dict)
|
|
|
|
|
state = Column(
|
2026-01-30 14:48:00 +05:30
|
|
|
Enum("queued", "processed", "processing", "failed", name="queued_run_state"),
|
2025-09-09 14:37:32 +05:30
|
|
|
nullable=False,
|
|
|
|
|
default="queued",
|
|
|
|
|
)
|
|
|
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
|
|
|
processed_at = Column(DateTime(timezone=True), nullable=True)
|
|
|
|
|
|
|
|
|
|
# New retry-related fields
|
|
|
|
|
retry_count = Column(Integer, default=0, nullable=False, server_default=text("0"))
|
|
|
|
|
parent_queued_run_id = Column(Integer, ForeignKey("queued_runs.id"), nullable=True)
|
|
|
|
|
scheduled_for = Column(DateTime(timezone=True), nullable=True)
|
|
|
|
|
retry_reason = Column(String, nullable=True) # 'busy', 'no_answer', 'voicemail'
|
|
|
|
|
|
|
|
|
|
# Relationships
|
|
|
|
|
campaign = relationship("CampaignModel")
|
|
|
|
|
parent_queued_run = relationship("QueuedRunModel", remote_side=[id])
|
|
|
|
|
|
|
|
|
|
# Indexes
|
|
|
|
|
__table_args__ = (
|
|
|
|
|
Index("idx_queued_runs_campaign_state", "campaign_id", "state"),
|
|
|
|
|
Index("idx_queued_runs_created", "created_at"),
|
|
|
|
|
Index("idx_queued_runs_source_uuid", "source_uuid"),
|
|
|
|
|
Index(
|
|
|
|
|
"idx_queued_runs_scheduled", "scheduled_for"
|
|
|
|
|
), # New index for scheduled retries
|
|
|
|
|
# Optimized index for checking queued runs efficiently
|
|
|
|
|
Index(
|
|
|
|
|
"idx_queued_runs_campaign_state_optimized",
|
|
|
|
|
"campaign_id",
|
|
|
|
|
"state",
|
|
|
|
|
postgresql_where=text("state = 'queued'"),
|
|
|
|
|
),
|
|
|
|
|
# Optimized index for scheduled retries
|
|
|
|
|
Index(
|
|
|
|
|
"idx_queued_runs_scheduled_optimized",
|
|
|
|
|
"campaign_id",
|
|
|
|
|
"scheduled_for",
|
|
|
|
|
postgresql_where=text("scheduled_for IS NOT NULL"),
|
|
|
|
|
),
|
|
|
|
|
UniqueConstraint(
|
|
|
|
|
"campaign_id",
|
|
|
|
|
"source_uuid",
|
|
|
|
|
"retry_count",
|
|
|
|
|
name="unique_campaign_source_retry",
|
|
|
|
|
),
|
|
|
|
|
)
|
2025-11-15 17:32:37 +05:30
|
|
|
|
|
|
|
|
|
|
|
|
|
class EmbedTokenModel(Base):
|
|
|
|
|
"""Model for storing workflow embed tokens"""
|
|
|
|
|
|
|
|
|
|
__tablename__ = "embed_tokens"
|
|
|
|
|
|
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
|
token = Column(String(255), unique=True, nullable=False, index=True)
|
|
|
|
|
workflow_id = Column(
|
|
|
|
|
Integer,
|
|
|
|
|
ForeignKey("workflows.id", ondelete="CASCADE"),
|
|
|
|
|
nullable=False,
|
|
|
|
|
index=True,
|
|
|
|
|
)
|
|
|
|
|
organization_id = Column(
|
|
|
|
|
Integer,
|
|
|
|
|
ForeignKey("organizations.id", ondelete="CASCADE"),
|
|
|
|
|
nullable=False,
|
|
|
|
|
index=True,
|
|
|
|
|
)
|
|
|
|
|
allowed_domains = Column(JSON, nullable=True) # Array of whitelisted domains
|
|
|
|
|
settings = Column(JSON, nullable=True) # Widget customization settings
|
|
|
|
|
is_active = Column(Boolean, default=True, nullable=False, index=True)
|
|
|
|
|
usage_limit = Column(Integer, nullable=True) # Optional usage limit
|
|
|
|
|
usage_count = Column(Integer, default=0, nullable=False)
|
|
|
|
|
expires_at = Column(DateTime(timezone=True), nullable=True)
|
|
|
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
|
|
|
created_by = Column(
|
|
|
|
|
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
|
|
|
|
)
|
|
|
|
|
updated_at = Column(DateTime(timezone=True), nullable=True)
|
|
|
|
|
|
|
|
|
|
# Relationships
|
|
|
|
|
workflow = relationship("WorkflowModel")
|
|
|
|
|
organization = relationship("OrganizationModel")
|
|
|
|
|
creator = relationship("UserModel")
|
|
|
|
|
sessions = relationship(
|
|
|
|
|
"EmbedSessionModel", back_populates="embed_token", cascade="all, delete-orphan"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class EmbedSessionModel(Base):
|
|
|
|
|
"""Model for storing temporary embed sessions"""
|
|
|
|
|
|
|
|
|
|
__tablename__ = "embed_sessions"
|
|
|
|
|
|
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
|
session_token = Column(String(255), unique=True, nullable=False, index=True)
|
|
|
|
|
embed_token_id = Column(
|
|
|
|
|
Integer, ForeignKey("embed_tokens.id", ondelete="CASCADE"), nullable=False
|
|
|
|
|
)
|
|
|
|
|
workflow_run_id = Column(
|
|
|
|
|
Integer, ForeignKey("workflow_runs.id", ondelete="CASCADE"), nullable=True
|
|
|
|
|
)
|
|
|
|
|
client_ip = Column(String(45), nullable=True)
|
|
|
|
|
user_agent = Column(String(500), nullable=True)
|
|
|
|
|
origin = Column(String(255), nullable=True)
|
|
|
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
|
|
|
expires_at = Column(DateTime(timezone=True), nullable=False, index=True)
|
|
|
|
|
|
|
|
|
|
# Relationships
|
|
|
|
|
embed_token = relationship("EmbedTokenModel", back_populates="sessions")
|
|
|
|
|
workflow_run = relationship("WorkflowRunModel")
|
2025-12-22 14:08:30 +05:30
|
|
|
|
|
|
|
|
|
|
|
|
|
class AgentTriggerModel(Base):
|
|
|
|
|
"""Model for storing agent trigger mappings (UUID -> workflow_id).
|
|
|
|
|
|
|
|
|
|
This is a minimal lookup table that maps trigger UUIDs to workflows.
|
|
|
|
|
The trigger node in the workflow definition is the source of truth.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
__tablename__ = "agent_triggers"
|
|
|
|
|
|
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
|
|
2026-04-25 19:44:44 +05:30
|
|
|
# Globally unique trigger path (UUID format)
|
2025-12-22 14:08:30 +05:30
|
|
|
trigger_path = Column(String(36), unique=True, nullable=False, index=True)
|
|
|
|
|
|
|
|
|
|
# Link to workflow
|
|
|
|
|
workflow_id = Column(
|
|
|
|
|
Integer, ForeignKey("workflows.id", ondelete="CASCADE"), nullable=False
|
|
|
|
|
)
|
|
|
|
|
organization_id = Column(
|
|
|
|
|
Integer, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# State management (active/archived)
|
|
|
|
|
state = Column(
|
|
|
|
|
Enum(*[state.value for state in TriggerState], name="trigger_state"),
|
|
|
|
|
nullable=False,
|
|
|
|
|
default=TriggerState.ACTIVE.value,
|
|
|
|
|
server_default=text("'active'::trigger_state"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Audit
|
|
|
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
|
|
|
|
|
|
|
|
# Relationships
|
|
|
|
|
workflow = relationship("WorkflowModel")
|
|
|
|
|
organization = relationship("OrganizationModel")
|
|
|
|
|
|
|
|
|
|
# Indexes for performance
|
|
|
|
|
__table_args__ = (
|
|
|
|
|
Index("ix_agent_triggers_workflow_id", "workflow_id"),
|
|
|
|
|
Index("ix_agent_triggers_state", "state"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ExternalCredentialModel(Base):
|
|
|
|
|
"""Model for storing external authentication credentials.
|
|
|
|
|
|
|
|
|
|
Credentials are stored separately from webhook configurations to allow
|
|
|
|
|
reuse across multiple workflows and secure storage of sensitive data.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
__tablename__ = "external_credentials"
|
|
|
|
|
|
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
|
|
|
|
|
|
# Public UUID reference (used in APIs and workflow definitions)
|
|
|
|
|
# This prevents enumeration attacks and hides internal IDs
|
|
|
|
|
credential_uuid = Column(
|
|
|
|
|
String(36),
|
|
|
|
|
unique=True,
|
|
|
|
|
nullable=False,
|
|
|
|
|
index=True,
|
|
|
|
|
default=lambda: str(uuid.uuid4()),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Organization scoping
|
|
|
|
|
organization_id = Column(
|
|
|
|
|
Integer, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Credential metadata
|
|
|
|
|
name = Column(String, nullable=False) # Display name, e.g., "Salesforce API"
|
|
|
|
|
description = Column(String, nullable=True) # Optional description
|
|
|
|
|
|
|
|
|
|
# Credential type - uses enum from api/enums.py
|
|
|
|
|
credential_type = Column(
|
|
|
|
|
Enum(
|
|
|
|
|
*[t.value for t in WebhookCredentialType],
|
|
|
|
|
name="webhook_credential_type",
|
|
|
|
|
),
|
|
|
|
|
nullable=False,
|
|
|
|
|
default=WebhookCredentialType.NONE.value,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Encrypted credential data (JSON)
|
|
|
|
|
# Structure depends on credential_type:
|
|
|
|
|
# - api_key: {"header_name": "X-API-Key", "api_key": "value"}
|
|
|
|
|
# - bearer_token: {"token": "value"}
|
|
|
|
|
# - basic_auth: {"username": "user", "password": "value"}
|
|
|
|
|
# - custom_header: {"header_name": "X-Custom", "header_value": "value"}
|
|
|
|
|
credential_data = Column(JSON, nullable=False, default=dict)
|
|
|
|
|
|
|
|
|
|
# Audit fields
|
|
|
|
|
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
|
|
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
|
|
|
updated_at = Column(
|
|
|
|
|
DateTime(timezone=True),
|
|
|
|
|
default=lambda: datetime.now(UTC),
|
|
|
|
|
onupdate=lambda: datetime.now(UTC),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Soft delete for safety
|
|
|
|
|
is_active = Column(Boolean, default=True, nullable=False)
|
|
|
|
|
|
|
|
|
|
# Relationships
|
|
|
|
|
organization = relationship("OrganizationModel")
|
|
|
|
|
created_by_user = relationship("UserModel")
|
|
|
|
|
|
|
|
|
|
# Indexes and constraints
|
|
|
|
|
__table_args__ = (
|
|
|
|
|
Index("ix_webhook_credentials_organization_id", "organization_id"),
|
|
|
|
|
Index("ix_webhook_credentials_uuid", "credential_uuid"),
|
|
|
|
|
UniqueConstraint("organization_id", "name", name="unique_org_credential_name"),
|
|
|
|
|
)
|
2026-01-02 13:11:02 +05:30
|
|
|
|
|
|
|
|
|
feat(webhooks): durable retrying delivery for final webhooks (#478)
* feat(webhooks): durable retrying delivery for final webhooks
Final webhook nodes were fired inline with a single best-effort httpx POST
(run_integrations._execute_webhook_node). On a transient error the failure was
swallowed at three levels, so ARQ never retried and the final call report was
permanently lost -- leaving downstream receivers stuck (e.g. a CRM showing a
call as still "in conversation").
Replace the one-shot POST with a durable, idempotent delivery pipeline modelled
on the campaign retry pattern (persisted row + scheduled_for + bounded attempts):
- New webhook_deliveries table (WebhookDeliveryModel) is the source of truth.
Payload is rendered once and frozen so retries are deterministic; secrets are
not stored -- the credential is referenced by uuid and re-resolved at send time.
- run_integrations now persists a delivery row and enqueues deliver_webhook with
a deterministic ARQ job id instead of sending inline.
- deliver_webhook (new ARQ task) sends the request and:
* 2xx -> succeeded
* transient -> retry with capped exponential backoff (RequestError /
5xx / 408 / 425 / 429), up to max_attempts then dead_letter
* permanent 4xx -> dead_letter immediately (no pointless looping)
It is idempotent: a non-pending delivery is a no-op, so a duplicate enqueue or
sweeper re-injection can't double-send.
- sweep_webhook_deliveries cron (every 5 min) re-enqueues overdue pending
deliveries so nothing is lost to a worker restart / Redis flush.
- Stable X-Dograh-Delivery-Id / Workflow-Run-Id / Attempt headers let receivers
dedupe retried deliveries.
- enqueue_job now forwards ARQ job options (_job_id, _defer_by); failures log
repr(e) so empty-message errors like ConnectTimeout are diagnosable.
Config via DEFAULT_WEBHOOK_DELIVERY_CONFIG (env-overridable): max_attempts=5,
base_delay=30s, max_delay=600s, timeout=30s.
Tests cover payload rendering, persist+enqueue, success, transient retry,
retryable 5xx, permanent 4xx dead-letter, attempt exhaustion, and idempotency.
Migration verified to apply/rollback against Postgres; table/enum/indexes confirmed.
* fix(webhooks): atomic claim, safe success-recording, sweep paging, migration cleanup
Address review feedback on the webhook delivery pipeline:
- deliver_webhook now atomically claims a delivery (conditional UPDATE that
leases scheduled_for) before sending, so concurrent ARQ executions can't
double-send (the prior status=='pending' read was non-atomic).
- Recording success is moved out of the dead-letter try-block: if the receiver
accepted the payload (2xx) but the success DB-write fails, the row is left
pending for the sweeper to reconcile instead of being dead-lettered.
- The sweep keyset-paginates by id so a backlog over the page size is fully
drained, and logs the true re-enqueued total.
- Migration downgrade drops the enum via op.execute(DROP TYPE IF EXISTS ...)
instead of the deprecated op.get_bind().
* fix(webhooks): idempotent delivery creation and drop secret custom headers
Address the remaining review feedback:
- Add a (workflow_run_id, webhook_node_id) unique constraint and make
create_webhook_delivery a get-or-create returning (delivery, created). A
retried run_integrations now reuses the existing row instead of creating and
sending a duplicate final webhook; only a freshly-created row is enqueued.
- Stop persisting secret-looking custom headers (Authorization, X-API-Key,
Cookie, ...) in plaintext on the delivery row: they are dropped with a warning
pointing at the credential store (which is re-resolved securely at send time).
Non-secret custom headers are unaffected.
* fix(webhooks): harden idempotency key, secret-header match, sweep reclaim id
Address follow-up review feedback:
- webhook_node_id is now NOT NULL so a NULL can't slip past the
(workflow_run_id, webhook_node_id) unique constraint and create duplicates.
- Secret-header filtering matches normalized markers (auth/token/secret/cookie/
api-key/...) instead of an exact name list, catching variants like
X-Custom-Auth-Token while leaving benign headers (e.g. X-Idempotency-Key).
- The sweeper re-enqueues with a reclaim-specific job id (the lease timestamp)
so reconciling a delivered-but-unrecorded row isn't deduped against the
original attempt's already-completed ARQ job. The atomic claim still ensures
at most one send.
* fix(webhooks): scope delivery rows to workflow org
---------
Co-authored-by: Abhishek Kumar <abhishek@a6k.me>
2026-07-02 17:14:14 +01:00
|
|
|
class WebhookDeliveryModel(Base):
|
|
|
|
|
"""Durable record of an outbound webhook delivery attempt.
|
|
|
|
|
|
|
|
|
|
Final webhooks (e.g. a workflow's "Final Webhook" node) must not be lost to a
|
|
|
|
|
single transient network error. Instead of firing the HTTP request inline and
|
|
|
|
|
forgetting it, we persist one row per webhook node per workflow run and let an
|
|
|
|
|
ARQ task drive delivery with bounded, backed-off retries. The row is the source
|
|
|
|
|
of truth: it survives worker restarts and a periodic sweeper re-enqueues any
|
|
|
|
|
``pending`` delivery whose ``scheduled_for`` is overdue. After ``max_attempts``
|
|
|
|
|
transient failures (or on a permanent 4xx) the row is parked as ``dead_letter``
|
|
|
|
|
for inspection rather than retried forever.
|
|
|
|
|
|
|
|
|
|
Mirrors the campaign retry pattern (``QueuedRunModel``): persisted state,
|
|
|
|
|
``scheduled_for`` gating, a hard attempt ceiling, and a terminal failure state.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
__tablename__ = "webhook_deliveries"
|
|
|
|
|
|
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
|
|
|
|
|
|
# Stable idempotency key sent to the receiver so it can dedupe retries.
|
|
|
|
|
delivery_uuid = Column(
|
|
|
|
|
String(36),
|
|
|
|
|
unique=True,
|
|
|
|
|
nullable=False,
|
|
|
|
|
index=True,
|
|
|
|
|
default=lambda: str(uuid.uuid4()),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
workflow_run_id = Column(
|
|
|
|
|
Integer,
|
|
|
|
|
ForeignKey("workflow_runs.id", ondelete="CASCADE"),
|
|
|
|
|
nullable=False,
|
|
|
|
|
)
|
|
|
|
|
organization_id = Column(
|
|
|
|
|
Integer, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Frozen request definition. The payload is rendered once at enqueue time so
|
|
|
|
|
# retries are deterministic. Secrets are NOT stored here: the auth header is
|
|
|
|
|
# re-resolved from ``credential_uuid`` at send time (honours rotation/revocation).
|
|
|
|
|
webhook_name = Column(String, nullable=True)
|
|
|
|
|
endpoint_url = Column(String, nullable=False)
|
|
|
|
|
http_method = Column(String, nullable=False, default="POST")
|
|
|
|
|
payload = Column(JSON, nullable=False, default=dict)
|
|
|
|
|
custom_headers = Column(JSON, nullable=True)
|
|
|
|
|
credential_uuid = Column(String(36), nullable=True)
|
|
|
|
|
|
|
|
|
|
# Workflow node that produced this delivery. Combined with workflow_run_id it
|
|
|
|
|
# is the per-run/per-node idempotency key, so a retried run_integrations does
|
|
|
|
|
# not create (and send) a duplicate delivery for the same node. Non-nullable:
|
|
|
|
|
# a NULL would be distinct under the unique constraint and defeat the dedupe.
|
|
|
|
|
webhook_node_id = Column(String, nullable=False)
|
|
|
|
|
|
|
|
|
|
status = Column(
|
|
|
|
|
Enum(
|
|
|
|
|
"pending",
|
|
|
|
|
"succeeded",
|
|
|
|
|
"dead_letter",
|
|
|
|
|
name="webhook_delivery_status",
|
|
|
|
|
),
|
|
|
|
|
nullable=False,
|
|
|
|
|
default="pending",
|
|
|
|
|
server_default="pending",
|
|
|
|
|
)
|
|
|
|
|
attempt_count = Column(Integer, nullable=False, default=0, server_default=text("0"))
|
|
|
|
|
max_attempts = Column(Integer, nullable=False, default=5, server_default=text("5"))
|
|
|
|
|
# When the next attempt becomes due. NULL once terminal (succeeded/dead_letter).
|
|
|
|
|
scheduled_for = Column(DateTime(timezone=True), nullable=True)
|
|
|
|
|
|
|
|
|
|
last_status_code = Column(Integer, nullable=True)
|
|
|
|
|
last_error = Column(Text, nullable=True)
|
|
|
|
|
|
|
|
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
|
|
|
updated_at = Column(
|
|
|
|
|
DateTime(timezone=True),
|
|
|
|
|
default=lambda: datetime.now(UTC),
|
|
|
|
|
onupdate=lambda: datetime.now(UTC),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
__table_args__ = (
|
|
|
|
|
# Sweeper lookup: due pending deliveries.
|
|
|
|
|
Index(
|
|
|
|
|
"idx_webhook_deliveries_pending_scheduled",
|
|
|
|
|
"scheduled_for",
|
|
|
|
|
postgresql_where=text("status = 'pending'"),
|
|
|
|
|
),
|
|
|
|
|
Index("idx_webhook_deliveries_run", "workflow_run_id"),
|
|
|
|
|
# Per-run/per-node idempotency: one delivery per webhook node per run.
|
|
|
|
|
UniqueConstraint(
|
|
|
|
|
"workflow_run_id",
|
|
|
|
|
"webhook_node_id",
|
|
|
|
|
name="uq_webhook_deliveries_run_node",
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-01-02 13:11:02 +05:30
|
|
|
class ToolModel(Base):
|
|
|
|
|
"""Model for storing reusable tools that can be invoked during workflows.
|
|
|
|
|
|
|
|
|
|
Tools provide a standardized way to integrate external functionality - from
|
|
|
|
|
HTTP API calls to native integrations.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
__tablename__ = "tools"
|
|
|
|
|
|
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
|
|
|
|
|
|
# Public identifier (used in APIs and workflow references)
|
|
|
|
|
tool_uuid = Column(
|
|
|
|
|
String(36),
|
|
|
|
|
unique=True,
|
|
|
|
|
nullable=False,
|
|
|
|
|
index=True,
|
|
|
|
|
default=lambda: str(uuid.uuid4()),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Organization scoping
|
|
|
|
|
organization_id = Column(
|
|
|
|
|
Integer, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Tool metadata
|
|
|
|
|
name = Column(String(255), nullable=False)
|
|
|
|
|
description = Column(String, nullable=True)
|
|
|
|
|
|
|
|
|
|
# Tool category - uses enum from api/enums.py
|
|
|
|
|
category = Column(
|
|
|
|
|
Enum(
|
|
|
|
|
*[c.value for c in ToolCategory],
|
|
|
|
|
name="tool_category",
|
|
|
|
|
),
|
|
|
|
|
nullable=False,
|
|
|
|
|
default=ToolCategory.HTTP_API.value,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Icon configuration (for UI display)
|
|
|
|
|
icon = Column(String(50), nullable=True) # Icon identifier
|
|
|
|
|
icon_color = Column(String(7), nullable=True) # Hex color code
|
|
|
|
|
|
|
|
|
|
# Status management
|
|
|
|
|
status = Column(
|
|
|
|
|
Enum(
|
|
|
|
|
*[s.value for s in ToolStatus],
|
|
|
|
|
name="tool_status",
|
|
|
|
|
),
|
|
|
|
|
nullable=False,
|
|
|
|
|
default=ToolStatus.ACTIVE.value,
|
|
|
|
|
server_default=text("'active'::tool_status"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# The tool definition (JSONB) - contains schema_version for compatibility
|
|
|
|
|
# Structure depends on category:
|
|
|
|
|
# - http_api: {"schema_version": 1, "type": "http_api", "config": {...}}
|
|
|
|
|
definition = Column(JSON, nullable=False, default=dict)
|
|
|
|
|
|
|
|
|
|
# Audit fields
|
|
|
|
|
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
|
|
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
|
|
|
updated_at = Column(
|
|
|
|
|
DateTime(timezone=True),
|
|
|
|
|
default=lambda: datetime.now(UTC),
|
|
|
|
|
onupdate=lambda: datetime.now(UTC),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Relationships
|
|
|
|
|
organization = relationship("OrganizationModel")
|
|
|
|
|
created_by_user = relationship("UserModel")
|
|
|
|
|
|
|
|
|
|
# Indexes and constraints
|
|
|
|
|
__table_args__ = (
|
|
|
|
|
Index("ix_tools_organization_id", "organization_id"),
|
|
|
|
|
Index("ix_tools_uuid", "tool_uuid"),
|
|
|
|
|
Index("ix_tools_status", "status"),
|
|
|
|
|
Index("ix_tools_category", "category"),
|
|
|
|
|
)
|
2026-01-17 14:37:03 +05:30
|
|
|
|
|
|
|
|
|
|
|
|
|
class KnowledgeBaseDocumentModel(Base):
|
|
|
|
|
"""Model for storing document-level metadata in the knowledge base.
|
|
|
|
|
|
|
|
|
|
Each document represents a source file (PDF, DOCX, etc.) that has been
|
|
|
|
|
processed and chunked for retrieval.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
__tablename__ = "knowledge_base_documents"
|
|
|
|
|
|
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
|
|
|
|
|
|
# Public identifier for API references
|
|
|
|
|
document_uuid = Column(
|
|
|
|
|
String(36),
|
|
|
|
|
unique=True,
|
|
|
|
|
nullable=False,
|
|
|
|
|
index=True,
|
|
|
|
|
default=lambda: str(uuid.uuid4()),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Organization scoping
|
|
|
|
|
organization_id = Column(
|
|
|
|
|
Integer, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Document metadata
|
|
|
|
|
filename = Column(String(500), nullable=False)
|
|
|
|
|
file_size_bytes = Column(Integer, nullable=True)
|
|
|
|
|
file_hash = Column(String(64), nullable=True) # SHA-256 hash for deduplication
|
|
|
|
|
mime_type = Column(String(100), nullable=True)
|
|
|
|
|
|
2026-04-09 13:49:20 +05:30
|
|
|
# Retrieval mode: "chunked" (vector search) or "full_document" (return full text)
|
|
|
|
|
retrieval_mode = Column(
|
|
|
|
|
String(20), nullable=False, default="chunked", server_default="chunked"
|
|
|
|
|
)
|
|
|
|
|
full_text = Column(
|
|
|
|
|
Text, nullable=True
|
|
|
|
|
) # Stored when retrieval_mode is "full_document"
|
|
|
|
|
|
2026-01-17 14:37:03 +05:30
|
|
|
# Processing metadata
|
|
|
|
|
source_url = Column(String, nullable=True) # If document was fetched from URL
|
|
|
|
|
total_chunks = Column(Integer, nullable=False, default=0)
|
|
|
|
|
processing_status = Column(
|
|
|
|
|
Enum(
|
|
|
|
|
"pending",
|
|
|
|
|
"processing",
|
|
|
|
|
"completed",
|
|
|
|
|
"failed",
|
|
|
|
|
name="document_processing_status",
|
|
|
|
|
),
|
|
|
|
|
nullable=False,
|
|
|
|
|
default="pending",
|
|
|
|
|
server_default=text("'pending'::document_processing_status"),
|
|
|
|
|
)
|
|
|
|
|
processing_error = Column(Text, nullable=True)
|
|
|
|
|
|
|
|
|
|
# Docling conversion metadata
|
|
|
|
|
docling_metadata = Column(
|
|
|
|
|
JSON, nullable=False, default=dict
|
|
|
|
|
) # Store docling document metadata
|
|
|
|
|
|
|
|
|
|
# Custom metadata (user-defined tags, categories, etc.)
|
|
|
|
|
custom_metadata = Column(JSON, nullable=False, default=dict)
|
|
|
|
|
|
|
|
|
|
# Audit fields
|
|
|
|
|
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
|
|
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
|
|
|
updated_at = Column(
|
|
|
|
|
DateTime(timezone=True),
|
|
|
|
|
default=lambda: datetime.now(UTC),
|
|
|
|
|
onupdate=lambda: datetime.now(UTC),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Soft delete
|
|
|
|
|
is_active = Column(Boolean, default=True, nullable=False)
|
|
|
|
|
archived_at = Column(DateTime(timezone=True), nullable=True)
|
|
|
|
|
|
|
|
|
|
# Relationships
|
|
|
|
|
organization = relationship("OrganizationModel")
|
|
|
|
|
created_by_user = relationship("UserModel")
|
|
|
|
|
chunks = relationship(
|
|
|
|
|
"KnowledgeBaseChunkModel",
|
|
|
|
|
back_populates="document",
|
|
|
|
|
cascade="all, delete-orphan",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Indexes and constraints
|
|
|
|
|
__table_args__ = (
|
|
|
|
|
Index("ix_kb_documents_organization_id", "organization_id"),
|
|
|
|
|
Index("ix_kb_documents_uuid", "document_uuid"),
|
|
|
|
|
Index("ix_kb_documents_status", "processing_status"),
|
|
|
|
|
Index("ix_kb_documents_created_at", "created_at"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-03-16 15:04:08 +05:30
|
|
|
class WorkflowRecordingModel(Base):
|
2026-04-10 16:18:01 +05:30
|
|
|
"""Model for storing audio recordings scoped to an organization.
|
2026-03-16 15:04:08 +05:30
|
|
|
|
|
|
|
|
Recordings are used in hybrid prompts where parts of the output are pre-recorded
|
|
|
|
|
audio rather than dynamically generated TTS.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
__tablename__ = "workflow_recordings"
|
|
|
|
|
|
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
|
|
2026-04-10 11:54:00 +05:30
|
|
|
# Descriptive ID used in prompts (unique per organization)
|
|
|
|
|
recording_id = Column(String(64), nullable=False, index=True)
|
2026-03-16 15:04:08 +05:30
|
|
|
|
|
|
|
|
# Scoping
|
|
|
|
|
workflow_id = Column(
|
2026-04-10 16:18:01 +05:30
|
|
|
Integer, ForeignKey("workflows.id", ondelete="CASCADE"), nullable=True
|
2026-03-16 15:04:08 +05:30
|
|
|
)
|
|
|
|
|
organization_id = Column(
|
|
|
|
|
Integer, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-10 16:18:01 +05:30
|
|
|
# TTS configuration metadata (optional, legacy)
|
|
|
|
|
tts_provider = Column(String, nullable=True)
|
|
|
|
|
tts_model = Column(String, nullable=True)
|
|
|
|
|
tts_voice_id = Column(String, nullable=True)
|
2026-03-16 15:04:08 +05:30
|
|
|
|
|
|
|
|
# Content
|
|
|
|
|
transcript = Column(Text, nullable=False)
|
|
|
|
|
|
|
|
|
|
# Storage
|
|
|
|
|
storage_key = Column(String, nullable=False)
|
|
|
|
|
storage_backend = Column(
|
|
|
|
|
Enum("s3", "minio", name="recording_storage_backend"),
|
|
|
|
|
nullable=False,
|
|
|
|
|
default="s3",
|
|
|
|
|
server_default=text("'s3'::recording_storage_backend"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Extra metadata (file_size_bytes, duration_seconds, original_filename, mime_type, etc.)
|
|
|
|
|
recording_metadata = Column(
|
|
|
|
|
JSON, nullable=False, default=dict, server_default=text("'{}'::json")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Audit
|
|
|
|
|
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
|
|
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
|
|
|
|
|
|
|
|
# Soft delete
|
|
|
|
|
is_active = Column(Boolean, default=True, nullable=False)
|
|
|
|
|
|
|
|
|
|
# Relationships
|
|
|
|
|
workflow = relationship("WorkflowModel")
|
|
|
|
|
organization = relationship("OrganizationModel")
|
|
|
|
|
created_by_user = relationship("UserModel")
|
|
|
|
|
|
|
|
|
|
# Indexes
|
|
|
|
|
__table_args__ = (
|
2026-04-10 11:54:00 +05:30
|
|
|
UniqueConstraint(
|
|
|
|
|
"recording_id",
|
|
|
|
|
"organization_id",
|
2026-04-10 16:18:01 +05:30
|
|
|
name="uq_workflow_recordings_recording_id_org",
|
2026-04-10 11:54:00 +05:30
|
|
|
),
|
2026-03-16 15:04:08 +05:30
|
|
|
Index("ix_workflow_recordings_workflow_id", "workflow_id"),
|
|
|
|
|
Index("ix_workflow_recordings_org_id", "organization_id"),
|
|
|
|
|
Index("ix_workflow_recordings_recording_id", "recording_id"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-01-17 14:37:03 +05:30
|
|
|
class KnowledgeBaseChunkModel(Base):
|
|
|
|
|
"""Model for storing document chunks with vector embeddings.
|
|
|
|
|
|
|
|
|
|
Each chunk represents a portion of a document that has been:
|
|
|
|
|
1. Extracted and chunked by docling's HybridChunker
|
|
|
|
|
2. Optionally contextualized with surrounding information
|
|
|
|
|
3. Embedded into a vector representation for semantic search
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
__tablename__ = "knowledge_base_chunks"
|
|
|
|
|
|
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
|
|
|
|
|
|
# Link to parent document
|
|
|
|
|
document_id = Column(
|
|
|
|
|
Integer,
|
|
|
|
|
ForeignKey("knowledge_base_documents.id", ondelete="CASCADE"),
|
|
|
|
|
nullable=False,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Organization scoping (denormalized for efficient querying)
|
|
|
|
|
organization_id = Column(
|
|
|
|
|
Integer, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Chunk content
|
|
|
|
|
chunk_text = Column(Text, nullable=False) # The actual chunk text
|
|
|
|
|
contextualized_text = Column(
|
|
|
|
|
Text, nullable=True
|
|
|
|
|
) # Enriched text from chunker.contextualize()
|
|
|
|
|
|
|
|
|
|
# Chunk positioning and metadata
|
|
|
|
|
chunk_index = Column(Integer, nullable=False) # Position in document (0-based)
|
|
|
|
|
|
|
|
|
|
# Docling chunk metadata
|
|
|
|
|
chunk_metadata = Column(
|
|
|
|
|
JSON, nullable=False, default=dict
|
|
|
|
|
) # Store chunk.meta if available
|
|
|
|
|
|
|
|
|
|
# Embedding configuration
|
|
|
|
|
embedding_model = Column(
|
|
|
|
|
String(200), nullable=False
|
|
|
|
|
) # e.g., "sentence-transformers/all-MiniLM-L6-v2"
|
|
|
|
|
embedding_dimension = Column(
|
|
|
|
|
Integer, nullable=False
|
|
|
|
|
) # e.g., 384 for all-MiniLM-L6-v2
|
|
|
|
|
|
|
|
|
|
# Vector embedding (pgvector column)
|
|
|
|
|
# The dimension should match the embedding_dimension field
|
|
|
|
|
# Default: 1536 dimensions for OpenAI text-embedding-3-small
|
|
|
|
|
# SentenceTransformer (384-dim) also supported but stored as 384-dim vectors
|
|
|
|
|
embedding = Column(Vector(1536), nullable=True)
|
|
|
|
|
|
|
|
|
|
# Token count (useful for chunking strategy analysis)
|
|
|
|
|
token_count = Column(Integer, nullable=True)
|
|
|
|
|
|
|
|
|
|
# Timestamps
|
|
|
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
|
|
|
|
updated_at = Column(
|
|
|
|
|
DateTime(timezone=True),
|
|
|
|
|
default=lambda: datetime.now(UTC),
|
|
|
|
|
onupdate=lambda: datetime.now(UTC),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Relationships
|
|
|
|
|
document = relationship("KnowledgeBaseDocumentModel", back_populates="chunks")
|
|
|
|
|
organization = relationship("OrganizationModel")
|
|
|
|
|
|
|
|
|
|
# Indexes and constraints
|
|
|
|
|
__table_args__ = (
|
|
|
|
|
Index("ix_kb_chunks_document_id", "document_id"),
|
|
|
|
|
Index("ix_kb_chunks_organization_id", "organization_id"),
|
|
|
|
|
Index("ix_kb_chunks_chunk_index", "chunk_index"),
|
|
|
|
|
Index(
|
|
|
|
|
"ix_kb_chunks_embedding_model", "embedding_model"
|
|
|
|
|
), # For filtering by model
|
|
|
|
|
# Vector similarity search index (using IVFFlat or HNSW)
|
|
|
|
|
# IVFFlat is good for datasets with 10k-1M vectors
|
|
|
|
|
# HNSW is better for larger datasets but uses more memory
|
|
|
|
|
Index(
|
|
|
|
|
"ix_kb_chunks_embedding_ivfflat",
|
|
|
|
|
"embedding",
|
|
|
|
|
postgresql_using="ivfflat",
|
|
|
|
|
postgresql_with={"lists": 100}, # Adjust based on dataset size
|
|
|
|
|
postgresql_ops={"embedding": "vector_cosine_ops"},
|
|
|
|
|
),
|
|
|
|
|
)
|