Download backend/app/database.py from Seth0330/qualifier: direct link, hf CLI and curl.
- Browser
- Download file 61.9 kB
-
https://huggingface.co/spaces/Seth0330/qualifier/resolve/main/backend/app/database.py
- Command line
-
hf download hf://spaces/Seth0330/qualifier/backend/app/database.py
-
curl -L -o database.py https://huggingface.co/spaces/Seth0330/qualifier/resolve/main/backend/app/database.py
61.9 kB
| import time | |
| from sqlalchemy import ( | |
| create_engine, | |
| Column, | |
| Float, | |
| Integer, | |
| String, | |
| Text, | |
| DateTime, | |
| JSON, | |
| ForeignKey, | |
| UniqueConstraint, | |
| event, | |
| ) | |
| from sqlalchemy.ext.declarative import declarative_base | |
| from sqlalchemy.orm import Session, sessionmaker | |
| from datetime import datetime | |
| import os | |
| from pathlib import Path | |
| # DATABASE_URL (Postgres, e.g. Azure Database for PostgreSQL) takes over when set; otherwise this | |
| # falls back to the original SQLite-on-local-disk behavior unchanged (Hugging Face Spaces provides | |
| # persistent storage at /data; override with HF_DATA_DIR for local development where /data isn't | |
| # writable). Two deployments, two databases, same codebase — neither path touches the other. | |
| DATABASE_URL = os.getenv("DATABASE_URL", "").strip() | |
| if DATABASE_URL: | |
| # SQLAlchemy 1.4+/2.x rejects the "postgres://" scheme some providers still hand out (a | |
| # Heroku-era convention); normalize to "postgresql://" so it always works regardless of | |
| # which form the connection string arrives in. | |
| if DATABASE_URL.startswith("postgres://"): | |
| DATABASE_URL = "postgresql://" + DATABASE_URL[len("postgres://"):] | |
| engine = create_engine(DATABASE_URL, pool_pre_ping=True) | |
| else: | |
| DATA_DIR = Path(os.getenv("HF_DATA_DIR", "/data")) | |
| DATA_DIR.mkdir(parents=True, exist_ok=True) | |
| DB_PATH = os.getenv("HF_DATABASE_PATH", str(DATA_DIR / "emailout.db")) | |
| engine = create_engine( | |
| f"sqlite:///{DB_PATH}", | |
| connect_args={"check_same_thread": False, "timeout": 30}, | |
| ) | |
| SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) | |
| Base = declarative_base() | |
| IS_POSTGRES = engine.dialect.name == "postgresql" | |
| if not IS_POSTGRES: | |
| def _sqlite_pragmas(dbapi_connection, _connection_record): | |
| cursor = dbapi_connection.cursor() | |
| cursor.execute("PRAGMA journal_mode=WAL") | |
| cursor.execute("PRAGMA synchronous=NORMAL") | |
| cursor.execute("PRAGMA busy_timeout=30000") | |
| cursor.close() | |
| # Dialect-portable SQL fragments for the additive migrations and raw inserts below — Postgres has | |
| # no DATETIME type (use TIMESTAMP) and no datetime('now') function (use NOW()). Kept as constants | |
| # rather than scattering dialect checks through every migration line. | |
| SQL_DATETIME_TYPE = "TIMESTAMP" if IS_POSTGRES else "DATETIME" | |
| SQL_NOW = "NOW()" if IS_POSTGRES else "datetime('now')" | |
| def db_commit_with_retry(session: Session, *, attempts: int = 5) -> None: | |
| """Retry commits when SQLite is busy (common under concurrent webhooks + campaign ticks).""" | |
| last_err = None | |
| for i in range(max(1, attempts)): | |
| try: | |
| session.commit() | |
| return | |
| except Exception as e: | |
| last_err = e | |
| msg = str(e).lower() | |
| if "locked" not in msg and "busy" not in msg: | |
| raise | |
| session.rollback() | |
| time.sleep(min(0.05 * (2**i), 1.0)) | |
| if last_err: | |
| raise last_err | |
| class User(Base): | |
| __tablename__ = "users" | |
| id = Column(Integer, primary_key=True, index=True) | |
| google_sub = Column(String, unique=True, index=True, nullable=True) | |
| microsoft_sub = Column(String, unique=True, index=True, nullable=True) # oid:tid — see auth_common.upsert_user_by_provider | |
| microsoft_tenant_id = Column(String, nullable=True) | |
| email = Column(String, index=True) | |
| name = Column(String, nullable=True) | |
| picture = Column(String, nullable=True) | |
| google_refresh_token = Column(Text, nullable=True) # for Gmail send (invite emails); treat as secret | |
| microsoft_refresh_token = Column(Text, nullable=True) # for Graph mail send (invite emails); treat as secret | |
| linkedin_profile_display_name = Column(String, nullable=True) | |
| default_unipile_account_ref_id = Column(Integer, ForeignKey("unipile_accounts.id"), nullable=True) | |
| mailbox_profile_display_name = Column(String, nullable=True) | |
| default_mailbox_unipile_account_ref_id = Column(Integer, ForeignKey("unipile_accounts.id"), nullable=True) | |
| created_at = Column(DateTime, default=datetime.utcnow) | |
| class Tenant(Base): | |
| __tablename__ = "tenants" | |
| id = Column(Integer, primary_key=True, index=True) | |
| name = Column(String, nullable=False) | |
| created_at = Column(DateTime, default=datetime.utcnow) | |
| class TenantMembership(Base): | |
| __tablename__ = "tenant_memberships" | |
| id = Column(Integer, primary_key=True, index=True) | |
| user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) | |
| tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=False, index=True) | |
| role = Column(String, default="member", nullable=False) # admin | member | |
| team_id = Column(Integer, ForeignKey("teams.id"), nullable=True, index=True) | |
| created_at = Column(DateTime, default=datetime.utcnow) | |
| __table_args__ = (UniqueConstraint("user_id", "tenant_id", name="uq_user_tenant"),) | |
| class Team(Base): | |
| """A sales team headed by one manager (TenantMembership.team_id points here). The manager | |
| is themself a member of their own team, so 'my team' queries naturally include their own | |
| records alongside the members they head.""" | |
| __tablename__ = "teams" | |
| id = Column(Integer, primary_key=True, index=True) | |
| tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=False, index=True) | |
| name = Column(String, nullable=False) | |
| manager_user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) | |
| created_at = Column(DateTime, default=datetime.utcnow) | |
| updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) | |
| class Invitation(Base): | |
| __tablename__ = "invitations" | |
| id = Column(Integer, primary_key=True, index=True) | |
| tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=False, index=True) | |
| email = Column(String, nullable=False, index=True) | |
| token_hash = Column(String(64), unique=True, nullable=False, index=True) | |
| role = Column(String, default="member") | |
| team_id = Column(Integer, ForeignKey("teams.id"), nullable=True) | |
| invited_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True) | |
| expires_at = Column(DateTime, nullable=False) | |
| created_at = Column(DateTime, default=datetime.utcnow) | |
| accepted_at = Column(DateTime, nullable=True) | |
| class UploadedFile(Base): | |
| __tablename__ = "uploaded_files" | |
| id = Column(Integer, primary_key=True, index=True) | |
| tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=True, index=True) | |
| file_id = Column(String, unique=True, index=True) | |
| filename = Column(String) | |
| contact_count = Column(Integer) | |
| file_path = Column(String) | |
| sequence_plan_json = Column(Text, nullable=True) | |
| campaign_name = Column(String, nullable=True) # user-facing campaign name — the backend record of "this campaign exists" | |
| status = Column(String, default="draft") # draft | generating | ready | launched | |
| # Email send pacing (campaign-setup-time config) — NULL timezone means no business-hours restriction. | |
| email_timezone = Column(String, nullable=True) # IANA zone name, e.g. "America/New_York" | |
| email_business_hours_start = Column(Integer, default=8) # local hour, inclusive, 0-23 | |
| email_business_hours_end = Column(Integer, default=18) # local hour, exclusive, 0-23 | |
| email_business_days = Column(String, default="1,2,3,4,5") # CSV ISO weekdays, Mon=1..Sun=7 | |
| email_send_interval_seconds = Column(Integer, default=60) # min gap between two email sends for this campaign | |
| generation_cancel_requested = Column(Integer, default=0) # 0/1 flag checked mid-stream by the SSE generator | |
| created_at = Column(DateTime, default=datetime.utcnow) | |
| updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) | |
| class Prompt(Base): | |
| __tablename__ = "prompts" | |
| id = Column(Integer, primary_key=True, index=True) | |
| tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=True, index=True) | |
| file_id = Column(String, index=True) | |
| product_name = Column(String) | |
| prompt_template = Column(Text) | |
| prompt_kind = Column(String, default="email") # email | linkedin | |
| created_at = Column(DateTime, default=datetime.utcnow) | |
| class GeneratedSequence(Base): | |
| __tablename__ = "generated_sequences" | |
| id = Column(Integer, primary_key=True, index=True) | |
| tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=True, index=True) | |
| file_id = Column(String, index=True) | |
| sequence_id = Column(Integer) # Contact sequence number | |
| email_number = Column(Integer, default=1) # Email number in sequence (1-10) | |
| channel = Column(String, default="email") # email | linkedin | |
| first_name = Column(String) | |
| last_name = Column(String) | |
| email = Column(String) | |
| company = Column(String) | |
| title = Column(String) | |
| product = Column(String) | |
| subject = Column(String) | |
| email_content = Column(Text) | |
| step_order = Column(Integer, nullable=True) | |
| created_at = Column(DateTime, default=datetime.utcnow) | |
| class Contact(Base): | |
| __tablename__ = "contacts" | |
| id = Column(Integer, primary_key=True, index=True) | |
| tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=True, index=True) | |
| file_id = Column(String, index=True) | |
| row_index = Column(Integer) | |
| first_name = Column(String) | |
| last_name = Column(String) | |
| email = Column(String, index=True) | |
| company = Column(String) | |
| title = Column(String) | |
| phone = Column(String, default="") | |
| source = Column(String, default="apollo_csv") | |
| raw_data = Column(JSON) | |
| # UniPile LinkedIn outreach (set when a campaign invite/DM is executed) | |
| unipile_provider_id = Column(String, nullable=True, index=True) | |
| linkedin_invite_sent_at = Column(DateTime, nullable=True) | |
| linkedin_invite_pending = Column(Integer, default=0) # 1 = waiting on acceptance webhook | |
| linkedin_connection_accepted_at = Column(DateTime, nullable=True) | |
| linkedin_last_followup_sent_at = Column(DateTime, nullable=True) | |
| linkedin_followup_next_email_number = Column(Integer, nullable=True) | |
| outreach_stopped_at = Column(DateTime, nullable=True) | |
| outreach_stop_reason = Column(String, nullable=True) | |
| owner_user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) | |
| created_at = Column(DateTime, default=datetime.utcnow()) | |
| class Company(Base): | |
| """Account-level record — either created manually or synced (by name match, one-directionally) | |
| from distinct Contact.company values. Not linked to Contact via a foreign key: association is | |
| a case-insensitive name match, kept intentionally soft so this table is fully additive and | |
| never touches existing Contact/CrmLead/CrmDeal rows or behavior.""" | |
| __tablename__ = "companies" | |
| id = Column(Integer, primary_key=True, index=True) | |
| tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=True, index=True) | |
| name = Column(String, nullable=False, index=True) | |
| category = Column(String, default="") # A | B | C | "" | |
| type = Column(String, default="") # Existing | New | "" | |
| company_name_for_emails = Column(String, default="") | |
| industry = Column(String, default="") | |
| employees = Column(String, default="") | |
| annual_revenue = Column(String, default="") | |
| last_raised_at = Column(String, default="") | |
| website = Column(String, default="") | |
| city = Column(String, default="") | |
| state = Column(String, default="") | |
| country = Column(String, default="") | |
| phone = Column(String, default="") # main company line — company-level, synced like the rest | |
| source = Column(String, default="manual") # manual | synced_from_contacts | |
| owner_user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) | |
| created_at = Column(DateTime, default=datetime.utcnow) | |
| updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) | |
| class ContactActivity(Base): | |
| """A logged Call or Visit against a Contact — who logged it, what was discussed, when. | |
| Powers the per-contact and per-company activity timelines. Always tied to a real Contact | |
| row (never a bare Lead) so company-level rollups can reuse the same case-insensitive | |
| Contact.company name match already used for associated-contacts lists.""" | |
| __tablename__ = "contact_activities" | |
| id = Column(Integer, primary_key=True, index=True) | |
| tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=True, index=True) | |
| contact_id = Column(Integer, ForeignKey("contacts.id"), nullable=False, index=True) | |
| user_id = Column(Integer, ForeignKey("users.id"), nullable=True) | |
| activity_type = Column(String, nullable=False) # "call" | "visit" | |
| notes = Column(Text, default="") | |
| created_at = Column(DateTime, default=datetime.utcnow, index=True) | |
| class CrmLead(Base): | |
| """Lead synced from Smartlead replies (webhook) — CRM pipeline status is local.""" | |
| __tablename__ = "crm_leads" | |
| id = Column(Integer, primary_key=True, index=True) | |
| tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=True, index=True) | |
| smartlead_lead_id = Column(String, index=True) | |
| campaign_id = Column(String, index=True) | |
| campaign_name = Column(String) | |
| email = Column(String, index=True) | |
| first_name = Column(String) | |
| last_name = Column(String) | |
| company_name = Column(String) | |
| title = Column(String) | |
| phone = Column(String, default="") # the lead's own personal phone — not company-synced | |
| last_reply_subject = Column(String) | |
| last_reply_body = Column(Text) | |
| last_reply_at = Column(DateTime, nullable=True) | |
| crm_status = Column(String, default="new_lead") # new_lead|contacted|qualified|unqualified|none | |
| contact_id = Column(Integer, nullable=True) # links to contacts.id after "Move to Contacts" | |
| owner_user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) | |
| raw_webhook = Column(JSON) | |
| created_at = Column(DateTime, default=datetime.utcnow) | |
| updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) | |
| class CrmDeal(Base): | |
| """Pipeline deal (often converted from a lead).""" | |
| __tablename__ = "crm_deals" | |
| id = Column(Integer, primary_key=True, index=True) | |
| tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=True, index=True) | |
| name = Column(String, index=True) | |
| stage = Column(String, default="new") # new|discovery|proposal|negotiation|won|lost | |
| owner_user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) | |
| owner_initials = Column(String, default="") # display cache; derived from User when owner_user_id set | |
| deal_value = Column(Integer, nullable=True) # whole USD (nullable) | |
| revenue_type = Column(String, default="arr") # arr|qrr|mrr|one_time | |
| contact_display = Column(String, default="") # primary person label | |
| account_name = Column(String, default="") | |
| expected_close_date = Column(DateTime, nullable=True) | |
| close_probability = Column(Integer, default=10) # 0–100 | |
| country = Column(String, default="") | |
| last_interaction_at = Column(DateTime, nullable=True) | |
| source_lead_id = Column(Integer, nullable=True) | |
| source_campaign_name = Column(String, default="") | |
| contact_id = Column(Integer, nullable=True) | |
| # When stage is lost: reason + optional detail fields | |
| lost_reason = Column(String, default="") # price|no_decision|chose_another|didnt_meet_expectation|unknown | |
| lost_price_discount_pct = Column(Float, nullable=True) | |
| lost_chose_product_name = Column(String, default="") | |
| # When stage is won: invoicing / PO snapshot | |
| won_po_number = Column(String, default="") | |
| won_customer_legal_name = Column(String, default="") | |
| won_customer_address = Column(Text, default="") | |
| won_contact_person_name = Column(String, default="") | |
| won_channel_partner_name = Column(String, default="") | |
| won_note_to_customer = Column(Text, default="") | |
| won_note_to_accounts = Column(Text, default="") | |
| won_line_items = Column(JSON, nullable=True) | |
| won_at = Column(DateTime, nullable=True) # first transition to won (for quarterly attribution) | |
| created_at = Column(DateTime, default=datetime.utcnow) | |
| updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) | |
| class SmartleadRun(Base): | |
| __tablename__ = "smartlead_runs" | |
| id = Column(Integer, primary_key=True, index=True) | |
| tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=True, index=True) | |
| file_id = Column(String, index=True) | |
| run_id = Column(String, unique=True, index=True) | |
| campaign_id = Column(String, index=True) | |
| campaign_name = Column(String) | |
| mode = Column(String) # 'existing' or 'new' | |
| steps_count = Column(Integer) | |
| dry_run = Column(Integer, default=0) # 0 = false, 1 = true | |
| total_leads = Column(Integer) | |
| added_leads = Column(Integer, default=0) | |
| skipped_leads = Column(Integer, default=0) | |
| failed_leads = Column(Integer, default=0) | |
| error_details = Column(Text) # JSON string of errors | |
| status = Column(String) # 'pending', 'completed', 'failed' | |
| created_at = Column(DateTime, default=datetime.utcnow) | |
| completed_at = Column(DateTime, nullable=True) | |
| class UnipileAccount(Base): | |
| __tablename__ = "unipile_accounts" | |
| id = Column(Integer, primary_key=True, index=True) | |
| tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=True, index=True) | |
| user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) | |
| label = Column(String, default="") | |
| provider = Column(String, default="LINKEDIN") | |
| unipile_account_id = Column(String, index=True) | |
| status = Column(String, default="pending") | |
| auth_mode = Column(String, default="hosted") | |
| metadata_json = Column(JSON, nullable=True) | |
| # Safe-sending caps (LinkedIn accounts only, harmless/unread on mailbox accounts). Defaults are | |
| # conservative research-backed numbers — Unipile's own platform ceiling is ~80-100 connects/day, | |
| # but sustained volume well under that is what actually avoids LinkedIn's restriction flags. | |
| linkedin_daily_connection_cap = Column(Integer, default=20) | |
| linkedin_daily_message_cap = Column(Integer, default=40) | |
| linkedin_weekly_connection_cap = Column(Integer, default=100) | |
| linkedin_weekly_message_cap = Column(Integer, default=200) | |
| created_at = Column(DateTime, default=datetime.utcnow) | |
| updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) | |
| class LinkedinCampaign(Base): | |
| __tablename__ = "linkedin_campaigns" | |
| id = Column(Integer, primary_key=True, index=True) | |
| tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=True, index=True) | |
| user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) | |
| name = Column(String, index=True) | |
| status = Column(String, default="draft") # draft | generated | running | completed | failed | |
| unipile_account_ref_id = Column(Integer, ForeignKey("unipile_accounts.id"), nullable=False, index=True) | |
| file_id = Column(String, index=True, nullable=True) | |
| contact_count = Column(Integer, default=0) | |
| prompt_template = Column(Text, default="") | |
| execution_result = Column(JSON, nullable=True) | |
| followup_interval_hours = Column(Integer, default=72) | |
| created_at = Column(DateTime, default=datetime.utcnow) | |
| updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) | |
| executed_at = Column(DateTime, nullable=True) | |
| class FileOutreachExecution(Base): | |
| """Server-side orchestration for wizard email+LinkedIn sequences (per uploaded CSV file).""" | |
| __tablename__ = "file_outreach_executions" | |
| id = Column(Integer, primary_key=True, index=True) | |
| tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=True, index=True) | |
| user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) | |
| file_id = Column(String, index=True, nullable=False) | |
| status = Column(String, default="idle") # idle | running | paused | completed | |
| progress_done = Column(Integer, default=0) | |
| progress_total = Column(Integer, default=0) | |
| stats_json = Column(Text, nullable=True) | |
| cursor_json = Column(Text, nullable=True) # delivery_started_at, etc. | |
| last_error = Column(Text, nullable=True) | |
| linkedin_followup_interval_hours = Column(Integer, default=72) | |
| created_at = Column(DateTime, default=datetime.utcnow) | |
| updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) | |
| __table_args__ = (UniqueConstraint("tenant_id", "file_id", name="uq_outreach_exec_tenant_file"),) | |
| class OutreachSendReceipt(Base): | |
| """One row per (file, contact, sequence action slot) send attempt.""" | |
| __tablename__ = "outreach_send_receipts" | |
| id = Column(Integer, primary_key=True, index=True) | |
| tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=True, index=True) | |
| file_id = Column(String, index=True, nullable=False) | |
| contact_id = Column(Integer, ForeignKey("contacts.id"), nullable=False, index=True) | |
| step_slot = Column(Integer, nullable=False) | |
| channel = Column(String, nullable=False) | |
| action = Column(String, default="") | |
| status = Column(String, default="pending") # sent | failed | skipped | |
| detail = Column(Text, nullable=True) | |
| unipile_account_ref_id = Column(Integer, ForeignKey("unipile_accounts.id"), nullable=True, index=True) | |
| unipile_message_id = Column(String, nullable=True) | |
| unipile_tracking_id = Column(String, nullable=True, index=True) | |
| smtp_message_id = Column(String, nullable=True, index=True) | |
| tracking_label = Column(String(220), nullable=True, index=True) | |
| opened_at = Column(DateTime, nullable=True) | |
| open_count = Column(Integer, default=0) | |
| replied_at = Column(DateTime, nullable=True) | |
| bounced_at = Column(DateTime, nullable=True) | |
| created_at = Column(DateTime, default=datetime.utcnow) | |
| __table_args__ = ( | |
| UniqueConstraint("tenant_id", "file_id", "contact_id", "step_slot", name="uq_outreach_receipt_slot"), | |
| ) | |
| class UnipileHostedAuthState(Base): | |
| __tablename__ = "unipile_hosted_auth_states" | |
| id = Column(Integer, primary_key=True, index=True) | |
| tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=True, index=True) | |
| user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) | |
| token = Column(String, unique=True, index=True) | |
| provider = Column(String, default="LINKEDIN") | |
| label = Column(String, default="") | |
| expires_at = Column(DateTime, nullable=True) | |
| used_at = Column(DateTime, nullable=True) | |
| created_at = Column(DateTime, default=datetime.utcnow) | |
| class CtaForm(Base): | |
| """Embeddable lead-capture form (WordPress / external sites → Leads).""" | |
| __tablename__ = "cta_forms" | |
| id = Column(Integer, primary_key=True, index=True) | |
| tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=False, index=True) | |
| user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) | |
| public_id = Column(String, unique=True, index=True, nullable=False) | |
| name = Column(String, nullable=False, default="") | |
| fields = Column(JSON, nullable=False, default=list) | |
| embed_width = Column(String, default="100%") | |
| embed_height = Column(String, default="480") | |
| is_active = Column(Integer, default=1) | |
| created_at = Column(DateTime, default=datetime.utcnow) | |
| updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) | |
| class LandingPage(Base): | |
| """AI-generated public landing pages with CTA form embed.""" | |
| __tablename__ = "landing_pages" | |
| id = Column(Integer, primary_key=True, index=True) | |
| tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=False, index=True) | |
| user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) | |
| public_id = Column(String, unique=True, index=True, nullable=False) | |
| name = Column(String, nullable=False, default="") | |
| prompt = Column(String, default="") | |
| content = Column(JSON, nullable=False, default=dict) | |
| cta_form_id = Column(Integer, ForeignKey("cta_forms.id"), nullable=True, index=True) | |
| banner_asset = Column(String, nullable=True) | |
| logo_asset = Column(String, nullable=True) | |
| is_active = Column(Integer, default=1) | |
| created_at = Column(DateTime, default=datetime.utcnow) | |
| updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) | |
| class ChatAgentConfig(Base): | |
| """One row per tenant: Chat Agent widget config, Drive connection, website source, model choice.""" | |
| __tablename__ = "chat_agent_configs" | |
| id = Column(Integer, primary_key=True, index=True) | |
| tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=False, unique=True, index=True) | |
| is_enabled = Column(Integer, default=1) | |
| public_id = Column(String, unique=True, index=True, nullable=False) | |
| greeting_message = Column( | |
| Text, | |
| default="Hi! 👋 I'm happy to help answer any questions. What's your name and email, in case we get disconnected?", | |
| ) | |
| chat_model_provider = Column(String, default="openai") # openai | gemini | |
| website_base_url = Column(String, default="") | |
| website_status = Column(String, default="not_configured") # not_configured|idle|running|success|error | |
| website_last_indexed_at = Column(DateTime, nullable=True) | |
| drive_folder_id = Column(String, default="") | |
| drive_folder_url = Column(String, default="") | |
| drive_refresh_token = Column(Text, nullable=True) | |
| drive_connected_email = Column(String, default="") | |
| drive_status = Column(String, default="not_connected") # not_connected|idle|running|success|error | |
| drive_last_indexed_at = Column(DateTime, nullable=True) | |
| reindex_interval_hours = Column(Integer, default=24) | |
| # Opt-in, off by default: lets the agentic loop call a Gemini web-search tool when the | |
| # knowledge base doesn't have enough for a detailed answer (e.g. a named competitor, | |
| # industry/technology background). Results are always presented as unverified external | |
| # content — see SKILLS-SECURITY.md for the residual risk this doesn't eliminate (a visitor's | |
| # message can still influence what query gets researched). | |
| enable_web_search = Column(Integer, default=0) | |
| created_at = Column(DateTime, default=datetime.utcnow) | |
| updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) | |
| class ChatAgentSkill(Base): | |
| """ | |
| Admin-editable skill, following Anthropic's Agent Skills anatomy (SKILL.md: YAML | |
| frontmatter name+description + markdown instructions, plus references/assets/templates/ | |
| scripts/evals). `instructions` (the "brain") is always compiled into the chat system prompt; | |
| references, assets, and templates are indexed into the same searchable knowledge base as | |
| Drive/website content so the agentic search_knowledge_base tool can surface them on demand. | |
| `scripts_json` can only be executed via the admin-only, sandboxed "Run script" action | |
| (chat_agent_script_runner.py) — the live public chat agent never executes it (see | |
| SKILLS-SECURITY.md for why). | |
| """ | |
| __tablename__ = "chat_agent_skills" | |
| id = Column(Integer, primary_key=True, index=True) | |
| tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=False, index=True) | |
| text = Column(Text, nullable=False, default="") # legacy; superseded by `instructions` below | |
| name = Column(String, default="") | |
| description = Column(Text, default="") | |
| instructions = Column(Text, default="") | |
| references_json = Column(JSON, nullable=True) # [{title, content}] | |
| assets_json = Column(JSON, nullable=True) # [{name, content}] | |
| templates_json = Column(JSON, nullable=True) # [{name, content}] | |
| scripts_json = Column(JSON, nullable=True) # [{name, description, language, content, last_run_*}] | |
| evals_json = Column(JSON, nullable=True) # [{prompt, assertion_type, assertion_value, last_run_*}] | |
| is_default = Column(Integer, default=0) | |
| is_active = Column(Integer, default=1) | |
| sort_order = Column(Integer, default=0) | |
| created_at = Column(DateTime, default=datetime.utcnow) | |
| updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) | |
| class ChatAgentDocument(Base): | |
| """One ingested source item (a Drive file or a crawled website page), converted to markdown.""" | |
| __tablename__ = "chat_agent_documents" | |
| id = Column(Integer, primary_key=True, index=True) | |
| tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=False, index=True) | |
| source_type = Column(String, nullable=False, index=True) # drive | website | |
| source_ref = Column(String, nullable=False, index=True) # drive file id | page URL | |
| title = Column(String, default="") | |
| content_markdown = Column(Text, default="") | |
| content_hash = Column(String, default="", index=True) | |
| external_modified_at = Column(DateTime, nullable=True) | |
| indexed_at = Column(DateTime, nullable=True) | |
| status = Column(String, default="ok") # ok | error | |
| error_detail = Column(Text, nullable=True) | |
| created_at = Column(DateTime, default=datetime.utcnow) | |
| updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) | |
| __table_args__ = ( | |
| UniqueConstraint("tenant_id", "source_type", "source_ref", name="uq_chat_doc_tenant_source"), | |
| ) | |
| class ChatAgentChunk(Base): | |
| """Embedded retrieval unit for a ChatAgentDocument.""" | |
| __tablename__ = "chat_agent_chunks" | |
| id = Column(Integer, primary_key=True, index=True) | |
| document_id = Column(Integer, ForeignKey("chat_agent_documents.id"), nullable=False, index=True) | |
| tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=False, index=True) | |
| chunk_index = Column(Integer, default=0) | |
| text = Column(Text, default="") | |
| embedding = Column(JSON, nullable=True) | |
| created_at = Column(DateTime, default=datetime.utcnow) | |
| class ChatAgentConversation(Base): | |
| """One website-visitor chat session (public widget).""" | |
| __tablename__ = "chat_agent_conversations" | |
| id = Column(Integer, primary_key=True, index=True) | |
| tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=False, index=True) | |
| session_id = Column(String, unique=True, index=True, nullable=False) | |
| visitor_name = Column(String, default="") | |
| visitor_email = Column(String, default="", index=True) | |
| visitor_company = Column(String, default="") | |
| lead_id = Column(Integer, ForeignKey("crm_leads.id"), nullable=True, index=True) | |
| page_url = Column(String, default="") | |
| started_at = Column(DateTime, default=datetime.utcnow) | |
| last_message_at = Column(DateTime, default=datetime.utcnow) | |
| class ChatAgentMessage(Base): | |
| __tablename__ = "chat_agent_messages" | |
| id = Column(Integer, primary_key=True, index=True) | |
| conversation_id = Column(Integer, ForeignKey("chat_agent_conversations.id"), nullable=False, index=True) | |
| role = Column(String, nullable=False) # user | assistant | |
| content = Column(Text, default="") | |
| # True when this reply used the search_the_web tool — surfaced to admins in the Test Chat | |
| # and Conversations views so they can tell live-web-sourced answers apart from knowledge-base | |
| # answers at a glance. | |
| used_web_search = Column(Integer, default=0) | |
| # Total LLM tokens (prompt + completion, summed across every round of the agentic loop) spent | |
| # generating this reply — 0 for user messages and for any reply where the provider didn't | |
| # report usage. Surfaced per-message and summed per-conversation in the Conversations tab. | |
| total_tokens = Column(Integer, default=0) | |
| created_at = Column(DateTime, default=datetime.utcnow) | |
| class ChatAgentIndexLog(Base): | |
| __tablename__ = "chat_agent_index_logs" | |
| id = Column(Integer, primary_key=True, index=True) | |
| tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=False, index=True) | |
| source_type = Column(String, nullable=False) # drive | website | |
| status = Column(String, default="running") # running | success | error | |
| documents_processed = Column(Integer, default=0) | |
| chunks_created = Column(Integer, default=0) | |
| started_at = Column(DateTime, default=datetime.utcnow) | |
| finished_at = Column(DateTime, nullable=True) | |
| detail = Column(Text, nullable=True) | |
| # --------------------------------------------------------------------------- | |
| # Qualifier Agent (RFQ Qualifier) — a deliberately SEPARATE skill/ingestion system from the | |
| # Chat Agent tables above, per design decision: each background agent (Qualifier, and later Quote | |
| # Estimator) gets its own independent skill model + knowledge base rather than a shared/generalized | |
| # one. Structurally the same anatomy as ChatAgentSkill/Document/Chunk/IndexLog (SKILL.md-compatible | |
| # skill row, hash-diffed embedded chunks, index-run log) but intentionally not reusing those tables | |
| # or any cross-FK to them — this whole block could be lifted into a standalone service/repo without | |
| # touching the Chat Agent feature. See qualifier_common.py / qualifier_ingest.py / qualifier_agent.py. | |
| # --------------------------------------------------------------------------- | |
| class QualifierSkill(Base): | |
| """ | |
| Admin-editable skill for the RFQ Qualifier agent — same SKILL.md anatomy as ChatAgentSkill | |
| (name+description+instructions, references/assets/templates/scripts/evals), compiled/parsed by | |
| qualifier_common.py. `instructions` (the qualify policy) is always injected into the agent's | |
| system prompt; references/assets/templates are indexed into qualifier_chunks alongside the | |
| Wittur pricelist so the agentic loop's search_pricelist-adjacent lookups can surface them. | |
| `scripts_json` is admin-run-only via qualifier_script_runner.py — the background agent never | |
| executes it. | |
| """ | |
| __tablename__ = "qualifier_skills" | |
| id = Column(Integer, primary_key=True, index=True) | |
| tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=False, index=True) | |
| name = Column(String, default="") | |
| description = Column(Text, default="") | |
| instructions = Column(Text, default="") | |
| references_json = Column(JSON, nullable=True) # [{title, content}] | |
| assets_json = Column(JSON, nullable=True) # [{name, content}] | |
| templates_json = Column(JSON, nullable=True) # [{name, content}] | |
| scripts_json = Column(JSON, nullable=True) # [{name, description, language, content, last_run_*}] | |
| evals_json = Column(JSON, nullable=True) # [{name, input_ref, expected_decision, expected_project_type, assertion_type, assertion_value, last_run_*}] | |
| is_default = Column(Integer, default=0) | |
| is_active = Column(Integer, default=1) | |
| sort_order = Column(Integer, default=0) | |
| created_at = Column(DateTime, default=datetime.utcnow) | |
| updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) | |
| class QualifierDocument(Base): | |
| """One ingested source item for the Qualifier's knowledge base — currently only source_type | |
| 'pricelist' (the Wittur pricelist PDF, re-ingested on demand) plus 'skill_reference' / | |
| 'skill_asset' / 'skill_template' (a QualifierSkill's own content, indexed the same way).""" | |
| __tablename__ = "qualifier_documents" | |
| id = Column(Integer, primary_key=True, index=True) | |
| tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=False, index=True) | |
| source_type = Column(String, nullable=False, index=True) # pricelist | skill_reference | skill_asset | skill_template | |
| source_ref = Column(String, nullable=False, index=True) # e.g. "pricelist:page:3" or "skill:1:reference:0" | |
| title = Column(String, default="") | |
| content_markdown = Column(Text, default="") | |
| content_hash = Column(String, default="", index=True) | |
| indexed_at = Column(DateTime, nullable=True) | |
| status = Column(String, default="ok") # ok | error | |
| error_detail = Column(Text, nullable=True) | |
| created_at = Column(DateTime, default=datetime.utcnow) | |
| updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) | |
| __table_args__ = ( | |
| UniqueConstraint("tenant_id", "source_type", "source_ref", name="uq_qualifier_doc_tenant_source"), | |
| ) | |
| class QualifierChunk(Base): | |
| """Embedded retrieval unit for a QualifierDocument — searched by the agent's search_pricelist | |
| tool call (qualifier_retrieval.py).""" | |
| __tablename__ = "qualifier_chunks" | |
| id = Column(Integer, primary_key=True, index=True) | |
| document_id = Column(Integer, ForeignKey("qualifier_documents.id"), nullable=False, index=True) | |
| tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=False, index=True) | |
| chunk_index = Column(Integer, default=0) | |
| text = Column(Text, default="") | |
| embedding = Column(JSON, nullable=True) | |
| created_at = Column(DateTime, default=datetime.utcnow) | |
| class QualifierIndexLog(Base): | |
| __tablename__ = "qualifier_index_logs" | |
| id = Column(Integer, primary_key=True, index=True) | |
| tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=False, index=True) | |
| source_type = Column(String, nullable=False) # pricelist | |
| status = Column(String, default="running") # running | success | error | |
| documents_processed = Column(Integer, default=0) | |
| chunks_created = Column(Integer, default=0) | |
| started_at = Column(DateTime, default=datetime.utcnow) | |
| finished_at = Column(DateTime, nullable=True) | |
| detail = Column(Text, nullable=True) | |
| class QualifierRun(Base): | |
| """One qualification run: an RFQ input (uploaded .eml, or PDF/DOCX spec, or a future | |
| inbox-polling trigger) processed through the Qualifier agent end to end. This is both the | |
| audit trail and the source of the "past runs" list in the dashboard.""" | |
| __tablename__ = "qualifier_runs" | |
| id = Column(Integer, primary_key=True, index=True) | |
| tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=False, index=True) | |
| skill_id = Column(Integer, ForeignKey("qualifier_skills.id"), nullable=True, index=True) | |
| created_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) | |
| trigger_type = Column(String, default="manual_upload") # manual_upload | api | inbox_poll (future) | |
| input_type = Column(String, nullable=False, default="") # eml | pdf | docx | |
| input_filename = Column(String, default="") | |
| input_file_path = Column(String, default="") # storage path/blob ref to the raw upload, see qualifier_storage.py | |
| project_name = Column(String, default="") | |
| sender_email = Column(String, default="") | |
| deadline_text = Column(String, default="") | |
| status = Column(String, default="running") # running | complete | error | |
| decision = Column(String, nullable=True) # qualify | disqualify | needs_review | |
| project_type = Column(String, nullable=True) # modernization | new_construction | unknown | |
| confidence = Column(Float, nullable=True) | |
| result_json = Column(JSON, nullable=True) # full structured QualifierDecision object | |
| extracted_text_excerpt = Column(Text, nullable=True) # what was actually handed to the model, for debugging | |
| error_detail = Column(Text, nullable=True) | |
| total_tokens = Column(Integer, default=0) | |
| started_at = Column(DateTime, default=datetime.utcnow) | |
| finished_at = Column(DateTime, nullable=True) | |
| def run_migrations(connection_engine): | |
| """Add tenant_id to legacy SQLite tables and attach rows to the default tenant.""" | |
| from sqlalchemy import inspect, text | |
| insp = inspect(connection_engine) | |
| tenant_tables = ( | |
| "uploaded_files", | |
| "prompts", | |
| "generated_sequences", | |
| "contacts", | |
| "crm_leads", | |
| "crm_deals", | |
| "smartlead_runs", | |
| "unipile_accounts", | |
| "linkedin_campaigns", | |
| "unipile_hosted_auth_states", | |
| "cta_forms", | |
| "landing_pages", | |
| "chat_agent_configs", | |
| "chat_agent_skills", | |
| "chat_agent_documents", | |
| "chat_agent_chunks", | |
| "chat_agent_conversations", | |
| "chat_agent_index_logs", | |
| ) | |
| with connection_engine.begin() as conn: | |
| if insp.has_table("tenants"): | |
| n = conn.execute(text("SELECT COUNT(*) FROM tenants")).scalar() | |
| if n == 0: | |
| conn.execute( | |
| text( | |
| f"INSERT INTO tenants (name, created_at) VALUES (:name, {SQL_NOW})" | |
| ), | |
| {"name": "Default workspace"}, | |
| ) | |
| default_tid = conn.execute(text("SELECT id FROM tenants ORDER BY id LIMIT 1")).scalar() | |
| if default_tid is None: | |
| default_tid = 1 | |
| insp = inspect(connection_engine) | |
| for tbl in tenant_tables: | |
| if not insp.has_table(tbl): | |
| continue | |
| cols = [c["name"] for c in insp.get_columns(tbl)] | |
| if "tenant_id" not in cols: | |
| conn.execute(text(f"ALTER TABLE {tbl} ADD COLUMN tenant_id INTEGER")) | |
| for tbl in tenant_tables: | |
| if not insp.has_table(tbl): | |
| continue | |
| conn.execute( | |
| text(f"UPDATE {tbl} SET tenant_id = :tid WHERE tenant_id IS NULL"), | |
| {"tid": default_tid}, | |
| ) | |
| insp = inspect(connection_engine) | |
| if insp.has_table("users"): | |
| ucols = [c["name"] for c in insp.get_columns("users")] | |
| if "google_refresh_token" not in ucols: | |
| conn.execute(text("ALTER TABLE users ADD COLUMN google_refresh_token TEXT")) | |
| if "linkedin_profile_display_name" not in ucols: | |
| conn.execute(text("ALTER TABLE users ADD COLUMN linkedin_profile_display_name TEXT")) | |
| if "default_unipile_account_ref_id" not in ucols: | |
| conn.execute(text("ALTER TABLE users ADD COLUMN default_unipile_account_ref_id INTEGER")) | |
| if "mailbox_profile_display_name" not in ucols: | |
| conn.execute(text("ALTER TABLE users ADD COLUMN mailbox_profile_display_name TEXT")) | |
| if "default_mailbox_unipile_account_ref_id" not in ucols: | |
| conn.execute(text("ALTER TABLE users ADD COLUMN default_mailbox_unipile_account_ref_id INTEGER")) | |
| if "microsoft_sub" not in ucols: | |
| conn.execute(text("ALTER TABLE users ADD COLUMN microsoft_sub TEXT")) | |
| conn.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS ix_users_microsoft_sub ON users (microsoft_sub)")) | |
| if "microsoft_tenant_id" not in ucols: | |
| conn.execute(text("ALTER TABLE users ADD COLUMN microsoft_tenant_id TEXT")) | |
| if "microsoft_refresh_token" not in ucols: | |
| conn.execute(text("ALTER TABLE users ADD COLUMN microsoft_refresh_token TEXT")) | |
| # google_sub was NOT NULL from day one (every user signed in with Google); a | |
| # Microsoft-only user has no Google identity, so relax it. Postgres-only (production) | |
| # — instant, no table rewrite. Skipped on SQLite (dev-only; ALTER COLUMN isn't | |
| # supported there and no production data is ever on SQLite). | |
| if IS_POSTGRES: | |
| col_info = next((c for c in insp.get_columns("users") if c["name"] == "google_sub"), None) | |
| if col_info is not None and not col_info.get("nullable", True): | |
| conn.execute(text("ALTER TABLE users ALTER COLUMN google_sub DROP NOT NULL")) | |
| insp = inspect(connection_engine) | |
| if insp.has_table("crm_deals"): | |
| dcols = [c["name"] for c in insp.get_columns("crm_deals")] | |
| if "owner_user_id" not in dcols: | |
| conn.execute(text("ALTER TABLE crm_deals ADD COLUMN owner_user_id INTEGER")) | |
| if "revenue_type" not in dcols: | |
| conn.execute( | |
| text("ALTER TABLE crm_deals ADD COLUMN revenue_type TEXT DEFAULT 'arr'") | |
| ) | |
| conn.execute( | |
| text( | |
| "UPDATE crm_deals SET revenue_type = 'arr' WHERE revenue_type IS NULL OR revenue_type = ''" | |
| ) | |
| ) | |
| if "lost_reason" not in dcols: | |
| conn.execute(text("ALTER TABLE crm_deals ADD COLUMN lost_reason TEXT DEFAULT ''")) | |
| if "lost_price_discount_pct" not in dcols: | |
| conn.execute(text("ALTER TABLE crm_deals ADD COLUMN lost_price_discount_pct REAL")) | |
| if "lost_chose_product_name" not in dcols: | |
| conn.execute(text("ALTER TABLE crm_deals ADD COLUMN lost_chose_product_name TEXT DEFAULT ''")) | |
| if "won_po_number" not in dcols: | |
| conn.execute(text("ALTER TABLE crm_deals ADD COLUMN won_po_number TEXT DEFAULT ''")) | |
| if "won_customer_legal_name" not in dcols: | |
| conn.execute(text("ALTER TABLE crm_deals ADD COLUMN won_customer_legal_name TEXT DEFAULT ''")) | |
| if "won_customer_address" not in dcols: | |
| conn.execute(text("ALTER TABLE crm_deals ADD COLUMN won_customer_address TEXT DEFAULT ''")) | |
| if "won_contact_person_name" not in dcols: | |
| conn.execute(text("ALTER TABLE crm_deals ADD COLUMN won_contact_person_name TEXT DEFAULT ''")) | |
| if "won_channel_partner_name" not in dcols: | |
| conn.execute(text("ALTER TABLE crm_deals ADD COLUMN won_channel_partner_name TEXT DEFAULT ''")) | |
| if "won_note_to_customer" not in dcols: | |
| conn.execute(text("ALTER TABLE crm_deals ADD COLUMN won_note_to_customer TEXT DEFAULT ''")) | |
| if "won_note_to_accounts" not in dcols: | |
| conn.execute(text("ALTER TABLE crm_deals ADD COLUMN won_note_to_accounts TEXT DEFAULT ''")) | |
| if "won_line_items" not in dcols: | |
| conn.execute(text("ALTER TABLE crm_deals ADD COLUMN won_line_items TEXT")) | |
| if "won_at" not in dcols: | |
| conn.execute(text(f"ALTER TABLE crm_deals ADD COLUMN won_at {SQL_DATETIME_TYPE}")) | |
| conn.execute( | |
| text( | |
| "UPDATE crm_deals SET won_at = updated_at WHERE stage = 'won' AND won_at IS NULL" | |
| ) | |
| ) | |
| insp = inspect(connection_engine) | |
| if insp.has_table("prompts"): | |
| pcols = [c["name"] for c in insp.get_columns("prompts")] | |
| if "prompt_kind" not in pcols: | |
| conn.execute(text("ALTER TABLE prompts ADD COLUMN prompt_kind TEXT DEFAULT 'email'")) | |
| conn.execute( | |
| text( | |
| "UPDATE prompts SET prompt_kind = 'email' WHERE prompt_kind IS NULL OR prompt_kind = ''" | |
| ) | |
| ) | |
| insp = inspect(connection_engine) | |
| if insp.has_table("generated_sequences"): | |
| gcols = [c["name"] for c in insp.get_columns("generated_sequences")] | |
| if "channel" not in gcols: | |
| conn.execute(text("ALTER TABLE generated_sequences ADD COLUMN channel TEXT DEFAULT 'email'")) | |
| conn.execute( | |
| text( | |
| "UPDATE generated_sequences SET channel = 'email' WHERE channel IS NULL OR channel = ''" | |
| ) | |
| ) | |
| gcols2 = [c["name"] for c in insp.get_columns("generated_sequences")] | |
| if "step_order" not in gcols2: | |
| conn.execute(text("ALTER TABLE generated_sequences ADD COLUMN step_order INTEGER")) | |
| insp = inspect(connection_engine) | |
| if insp.has_table("uploaded_files"): | |
| ufcols = [c["name"] for c in insp.get_columns("uploaded_files")] | |
| if "sequence_plan_json" not in ufcols: | |
| conn.execute(text("ALTER TABLE uploaded_files ADD COLUMN sequence_plan_json TEXT")) | |
| if "campaign_name" not in ufcols: | |
| conn.execute(text("ALTER TABLE uploaded_files ADD COLUMN campaign_name TEXT")) | |
| if "status" not in ufcols: | |
| conn.execute(text("ALTER TABLE uploaded_files ADD COLUMN status TEXT DEFAULT 'draft'")) | |
| if "updated_at" not in ufcols: | |
| conn.execute(text(f"ALTER TABLE uploaded_files ADD COLUMN updated_at {SQL_DATETIME_TYPE}")) | |
| if "email_timezone" not in ufcols: | |
| conn.execute(text("ALTER TABLE uploaded_files ADD COLUMN email_timezone TEXT")) | |
| if "email_business_hours_start" not in ufcols: | |
| conn.execute(text("ALTER TABLE uploaded_files ADD COLUMN email_business_hours_start INTEGER DEFAULT 8")) | |
| if "email_business_hours_end" not in ufcols: | |
| conn.execute(text("ALTER TABLE uploaded_files ADD COLUMN email_business_hours_end INTEGER DEFAULT 18")) | |
| if "email_business_days" not in ufcols: | |
| conn.execute(text("ALTER TABLE uploaded_files ADD COLUMN email_business_days TEXT DEFAULT '1,2,3,4,5'")) | |
| if "email_send_interval_seconds" not in ufcols: | |
| conn.execute(text("ALTER TABLE uploaded_files ADD COLUMN email_send_interval_seconds INTEGER DEFAULT 60")) | |
| if "generation_cancel_requested" not in ufcols: | |
| conn.execute(text("ALTER TABLE uploaded_files ADD COLUMN generation_cancel_requested INTEGER DEFAULT 0")) | |
| insp = inspect(connection_engine) | |
| if insp.has_table("chat_agent_conversations"): | |
| caccols = [c["name"] for c in insp.get_columns("chat_agent_conversations")] | |
| if "visitor_company" not in caccols: | |
| conn.execute(text("ALTER TABLE chat_agent_conversations ADD COLUMN visitor_company TEXT DEFAULT ''")) | |
| insp = inspect(connection_engine) | |
| if insp.has_table("unipile_accounts"): | |
| uacols = [c["name"] for c in insp.get_columns("unipile_accounts")] | |
| if "user_id" not in uacols: | |
| conn.execute(text("ALTER TABLE unipile_accounts ADD COLUMN user_id INTEGER")) | |
| if "linkedin_daily_connection_cap" not in uacols: | |
| conn.execute(text("ALTER TABLE unipile_accounts ADD COLUMN linkedin_daily_connection_cap INTEGER DEFAULT 20")) | |
| if "linkedin_daily_message_cap" not in uacols: | |
| conn.execute(text("ALTER TABLE unipile_accounts ADD COLUMN linkedin_daily_message_cap INTEGER DEFAULT 40")) | |
| if "linkedin_weekly_connection_cap" not in uacols: | |
| conn.execute(text("ALTER TABLE unipile_accounts ADD COLUMN linkedin_weekly_connection_cap INTEGER DEFAULT 100")) | |
| if "linkedin_weekly_message_cap" not in uacols: | |
| conn.execute(text("ALTER TABLE unipile_accounts ADD COLUMN linkedin_weekly_message_cap INTEGER DEFAULT 200")) | |
| insp = inspect(connection_engine) | |
| if insp.has_table("linkedin_campaigns"): | |
| lccols = [c["name"] for c in insp.get_columns("linkedin_campaigns")] | |
| if "user_id" not in lccols: | |
| conn.execute(text("ALTER TABLE linkedin_campaigns ADD COLUMN user_id INTEGER")) | |
| if "followup_interval_hours" not in lccols: | |
| conn.execute( | |
| text("ALTER TABLE linkedin_campaigns ADD COLUMN followup_interval_hours INTEGER DEFAULT 72") | |
| ) | |
| conn.execute( | |
| text( | |
| "UPDATE linkedin_campaigns SET followup_interval_hours = 72 WHERE followup_interval_hours IS NULL" | |
| ) | |
| ) | |
| insp = inspect(connection_engine) | |
| if insp.has_table("contacts"): | |
| ctcols = [c["name"] for c in insp.get_columns("contacts")] | |
| if "unipile_provider_id" not in ctcols: | |
| conn.execute(text("ALTER TABLE contacts ADD COLUMN unipile_provider_id TEXT")) | |
| if "linkedin_invite_sent_at" not in ctcols: | |
| conn.execute(text(f"ALTER TABLE contacts ADD COLUMN linkedin_invite_sent_at {SQL_DATETIME_TYPE}")) | |
| if "linkedin_invite_pending" not in ctcols: | |
| conn.execute(text("ALTER TABLE contacts ADD COLUMN linkedin_invite_pending INTEGER DEFAULT 0")) | |
| if "linkedin_connection_accepted_at" not in ctcols: | |
| conn.execute(text(f"ALTER TABLE contacts ADD COLUMN linkedin_connection_accepted_at {SQL_DATETIME_TYPE}")) | |
| if "linkedin_last_followup_sent_at" not in ctcols: | |
| conn.execute(text(f"ALTER TABLE contacts ADD COLUMN linkedin_last_followup_sent_at {SQL_DATETIME_TYPE}")) | |
| if "linkedin_followup_next_email_number" not in ctcols: | |
| conn.execute(text("ALTER TABLE contacts ADD COLUMN linkedin_followup_next_email_number INTEGER")) | |
| if "outreach_stopped_at" not in ctcols: | |
| conn.execute(text(f"ALTER TABLE contacts ADD COLUMN outreach_stopped_at {SQL_DATETIME_TYPE}")) | |
| if "outreach_stop_reason" not in ctcols: | |
| conn.execute(text("ALTER TABLE contacts ADD COLUMN outreach_stop_reason TEXT")) | |
| if "phone" not in ctcols: | |
| conn.execute(text("ALTER TABLE contacts ADD COLUMN phone TEXT DEFAULT ''")) | |
| if "owner_user_id" not in ctcols: | |
| conn.execute(text("ALTER TABLE contacts ADD COLUMN owner_user_id INTEGER")) | |
| insp = inspect(connection_engine) | |
| if insp.has_table("crm_leads"): | |
| leadcols = [c["name"] for c in insp.get_columns("crm_leads")] | |
| if "phone" not in leadcols: | |
| conn.execute(text("ALTER TABLE crm_leads ADD COLUMN phone TEXT DEFAULT ''")) | |
| if "owner_user_id" not in leadcols: | |
| conn.execute(text("ALTER TABLE crm_leads ADD COLUMN owner_user_id INTEGER")) | |
| insp = inspect(connection_engine) | |
| if insp.has_table("tenant_memberships"): | |
| tmcols = [c["name"] for c in insp.get_columns("tenant_memberships")] | |
| if "team_id" not in tmcols: | |
| conn.execute(text("ALTER TABLE tenant_memberships ADD COLUMN team_id INTEGER")) | |
| insp = inspect(connection_engine) | |
| if insp.has_table("companies"): | |
| compcols = [c["name"] for c in insp.get_columns("companies")] | |
| if "phone" not in compcols: | |
| conn.execute(text("ALTER TABLE companies ADD COLUMN phone TEXT DEFAULT ''")) | |
| if "type" not in compcols: | |
| conn.execute(text("ALTER TABLE companies ADD COLUMN type TEXT DEFAULT ''")) | |
| if "owner_user_id" not in compcols: | |
| conn.execute(text("ALTER TABLE companies ADD COLUMN owner_user_id INTEGER")) | |
| insp = inspect(connection_engine) | |
| if insp.has_table("file_outreach_executions"): | |
| ecols = [c["name"] for c in insp.get_columns("file_outreach_executions")] | |
| if "linkedin_followup_interval_hours" not in ecols: | |
| conn.execute( | |
| text( | |
| "ALTER TABLE file_outreach_executions ADD COLUMN linkedin_followup_interval_hours INTEGER DEFAULT 72" | |
| ) | |
| ) | |
| insp = inspect(connection_engine) | |
| if insp.has_table("outreach_send_receipts"): | |
| rcols = [c["name"] for c in insp.get_columns("outreach_send_receipts")] | |
| if "tracking_label" not in rcols: | |
| conn.execute(text("ALTER TABLE outreach_send_receipts ADD COLUMN tracking_label TEXT")) | |
| if "unipile_tracking_id" not in rcols: | |
| conn.execute(text("ALTER TABLE outreach_send_receipts ADD COLUMN unipile_tracking_id TEXT")) | |
| if "unipile_account_ref_id" not in rcols: | |
| conn.execute(text("ALTER TABLE outreach_send_receipts ADD COLUMN unipile_account_ref_id INTEGER")) | |
| if "smtp_message_id" not in rcols: | |
| conn.execute(text("ALTER TABLE outreach_send_receipts ADD COLUMN smtp_message_id TEXT")) | |
| if "opened_at" not in rcols: | |
| conn.execute(text(f"ALTER TABLE outreach_send_receipts ADD COLUMN opened_at {SQL_DATETIME_TYPE}")) | |
| if "open_count" not in rcols: | |
| conn.execute( | |
| text("ALTER TABLE outreach_send_receipts ADD COLUMN open_count INTEGER DEFAULT 0") | |
| ) | |
| if "replied_at" not in rcols: | |
| conn.execute(text(f"ALTER TABLE outreach_send_receipts ADD COLUMN replied_at {SQL_DATETIME_TYPE}")) | |
| if "bounced_at" not in rcols: | |
| conn.execute(text(f"ALTER TABLE outreach_send_receipts ADD COLUMN bounced_at {SQL_DATETIME_TYPE}")) | |
| insp = inspect(connection_engine) | |
| if insp.has_table("cta_forms"): | |
| ccols = [c["name"] for c in insp.get_columns("cta_forms")] | |
| if "embed_width" not in ccols: | |
| conn.execute(text("ALTER TABLE cta_forms ADD COLUMN embed_width TEXT DEFAULT '100%'")) | |
| if "embed_height" not in ccols: | |
| conn.execute(text("ALTER TABLE cta_forms ADD COLUMN embed_height TEXT DEFAULT '480'")) | |
| insp = inspect(connection_engine) | |
| if insp.has_table("chat_agent_skills"): | |
| skcols = [c["name"] for c in insp.get_columns("chat_agent_skills")] | |
| if "name" not in skcols: | |
| conn.execute(text("ALTER TABLE chat_agent_skills ADD COLUMN name TEXT DEFAULT ''")) | |
| if "description" not in skcols: | |
| conn.execute(text("ALTER TABLE chat_agent_skills ADD COLUMN description TEXT")) | |
| if "instructions" not in skcols: | |
| conn.execute(text("ALTER TABLE chat_agent_skills ADD COLUMN instructions TEXT")) | |
| if "references_json" not in skcols: | |
| conn.execute(text("ALTER TABLE chat_agent_skills ADD COLUMN references_json TEXT")) | |
| if "assets_json" not in skcols: | |
| conn.execute(text("ALTER TABLE chat_agent_skills ADD COLUMN assets_json TEXT")) | |
| if "templates_json" not in skcols: | |
| conn.execute(text("ALTER TABLE chat_agent_skills ADD COLUMN templates_json TEXT")) | |
| if "scripts_json" not in skcols: | |
| conn.execute(text("ALTER TABLE chat_agent_skills ADD COLUMN scripts_json TEXT")) | |
| if "evals_json" not in skcols: | |
| conn.execute(text("ALTER TABLE chat_agent_skills ADD COLUMN evals_json TEXT")) | |
| if "sort_order" not in skcols: | |
| conn.execute(text("ALTER TABLE chat_agent_skills ADD COLUMN sort_order INTEGER DEFAULT 0")) | |
| # Backfill legacy rows (created before `instructions` existed) so every skill has a | |
| # real name/description/instructions triple, matching the SKILL.md anatomy. | |
| conn.execute( | |
| text( | |
| "UPDATE chat_agent_skills SET instructions = text " | |
| "WHERE (instructions IS NULL OR instructions = '') AND text IS NOT NULL" | |
| ) | |
| ) | |
| conn.execute( | |
| text( | |
| "UPDATE chat_agent_skills SET name = 'skill-' || id " | |
| "WHERE name IS NULL OR name = ''" | |
| ) | |
| ) | |
| conn.execute( | |
| text( | |
| "UPDATE chat_agent_skills SET description = 'Legacy skill — edit to add a name and description' " | |
| "WHERE description IS NULL OR description = ''" | |
| ) | |
| ) | |
| # One-time consolidation: the Skills feature moved from 3 small default guardrail | |
| # skills to one comprehensive default skill. A tenant whose skills are EXACTLY the old | |
| # 3 defaults (all is_default=1, no custom skills added) gets them cleared here — | |
| # ensure_default_skills() (chat_agent_common.py) already reseeds automatically once a | |
| # tenant has zero skills, so it picks up the new comprehensive one on next access. Any | |
| # tenant with a custom (is_default=0) skill is left untouched, so real admin edits are | |
| # never silently destroyed. | |
| _old_default_names = sorted(["no-internal-pricing", "no-client-names", "no-sensitive-info"]) | |
| _rows = conn.execute(text("SELECT tenant_id, name, is_default FROM chat_agent_skills")).fetchall() | |
| _by_tenant: dict = {} | |
| for _tid, _name, _is_default in _rows: | |
| _by_tenant.setdefault(_tid, []).append((_name, _is_default)) | |
| for _tid, _items in _by_tenant.items(): | |
| _names = sorted(n for n, _ in _items) | |
| _all_default = all(bool(d) for _, d in _items) | |
| if _all_default and _names == _old_default_names: | |
| conn.execute( | |
| text("DELETE FROM chat_agent_skills WHERE tenant_id = :tid"), | |
| {"tid": _tid}, | |
| ) | |
| insp = inspect(connection_engine) | |
| if insp.has_table("chat_agent_configs"): | |
| cfgcols = [c["name"] for c in insp.get_columns("chat_agent_configs")] | |
| if "enable_web_search" not in cfgcols: | |
| conn.execute( | |
| text("ALTER TABLE chat_agent_configs ADD COLUMN enable_web_search INTEGER DEFAULT 0") | |
| ) | |
| if insp.has_table("chat_agent_messages"): | |
| msgcols = [c["name"] for c in insp.get_columns("chat_agent_messages")] | |
| if "used_web_search" not in msgcols: | |
| conn.execute( | |
| text("ALTER TABLE chat_agent_messages ADD COLUMN used_web_search INTEGER DEFAULT 0") | |
| ) | |
| if "total_tokens" not in msgcols: | |
| conn.execute( | |
| text("ALTER TABLE chat_agent_messages ADD COLUMN total_tokens INTEGER DEFAULT 0") | |
| ) | |
| insp = inspect(connection_engine) | |
| if insp.has_table("invitations"): | |
| invcols = [c["name"] for c in insp.get_columns("invitations")] | |
| if "team_id" not in invcols: | |
| conn.execute(text("ALTER TABLE invitations ADD COLUMN team_id INTEGER")) | |
| # Create tables then migrate legacy SQLite schemas | |
| Base.metadata.create_all(bind=engine) | |
| run_migrations(engine) | |
| def get_db(): | |
| db = SessionLocal() | |
| try: | |
| yield db | |
| finally: | |
| db.close() | |