build: 打通 FastAPI 与 Vue 最小工程、依赖与数据库连接

后端入口、配置、Redis 与 Alembic 迁移就位;前端 Vite 工程可安装可构建。
This commit is contained in:
WangShen007 committed 2026-09-22 20:15:00 +08:00
1 parent ec5835ca77
commit 977783c126
167 files changed
+26082

No files matched your search

+20
View File
@@ -0,0 +1,20 @@
[run]
source = app
omit =
*/tests/*
*/__init__.py
*/migrations/*
*/alembic/*
[report]
exclude_lines =
pragma: no cover
def __repr__
raise NotImplementedError
if __name__ == .__main__.:
pass
raise ImportError
except ImportError:
[html]
directory = coverage_html
+170
View File
@@ -0,0 +1,170 @@
# Database Configuration
DATABASE_URL=postgresql+psycopg://postgres:postgres@db:5432/replypier
# CORS Configuration add backend url to allow cors from frontend
CORS_ORIGINS=["http://localhost:3000","http://localhost:8000"]
# Firebase Configuration
FIREBASE_CREDENTIALS=app/config/firebase-config.json
# Widget Configuration # prod image remove :3000 port, since frontend is running on port 80
VITE_WIDGET_URL=http://localhost:8000
FRONTEND_URL=http://localhost:3000
# Public URL of THIS backend. Self-hosters MUST set this to their domain
# (e.g. https://support.example.com) — the chat widget derives its API/WS URLs
# from it, so if it stays localhost the embedded widget can't reach the backend.
BACKEND_URL=http://localhost:8000
# Public help center URL style:
# path -> {BACKEND_URL}/help/{slug}, served same-origin (no DNS/TLS/proxy). Default, best for self-host.
# subdomain -> https://{slug}.<HELP_CENTER_BASE_DOMAIN> (needs wildcard DNS + TLS; used by cloud).
# A verified custom domain always overrides this.
HELP_CENTER_PUBLIC_MODE=path
# JWT Configuration
# These sign your auth and conversation tokens - anyone who knows them can forge
# tokens for any user, so the placeholders below must be replaced. Outside
# ENVIRONMENT=development the app refuses to start while they are still in place.
# Generate each (64 hex characters) with:
# openssl rand -hex 32
JWT_SECRET_KEY=your_jwt_secret_key_here
JWT_ALGORITHM=HS256
CONVERSATION_SECRET_KEY=your_conversation_secret_key_here
# Encryption and Redis
# REQUIRED outside development: encrypts API keys, provider/OAuth credentials, chat
# messages, session summaries and agent memory at rest. Losing or changing it makes
# existing data unreadable, so back it up separately from the database.
#
# The value is a Fernet key that has been base64-encoded a second time: a
# 60-character string ending in '='. Generate one with either of:
# python -c "import base64;from cryptography.fernet import Fernet;print(base64.b64encode(Fernet.generate_key()).decode())"
# openssl rand -base64 32 | tr '+/' '-_' | tr -d '\n' | base64
#
# Leaving the placeholder below in place means: outside development the app
# refuses to start; in development it generates a throwaway key on every boot,
# so anything encrypted before a restart becomes unreadable after it.
ENCRYPTION_KEY=your_fernet_encryption_key_here
# Escape hatch for deployments created before ENCRYPTION_KEY had to be set, whose
# data is encrypted under the old public default. It downgrades the startup error
# to a warning so those instances still boot. Anyone with a copy of the database
# can read their conversations and stored credentials, so treat it as temporary.
# Moving off the old key means re-encrypting what it wrote, not just swapping the
# value - see the key-id rotation note at the top of app/core/encryption.py.
# ALLOW_INSECURE_ENCRYPTION_KEY=false
REDIS_ENABLED=true
REDIS_URL=redis://localhost:6379/0
# Environment
ENVIRONMENT=development
# S3 Configuration
S3_FILE_STORAGE=false
S3_BUCKET=replypier-uploads
S3_REGION=us-east-1
# Leave both blank on EC2/ECS/EKS to use the attached IAM role — boto3 then
# resolves credentials from its default chain. Set them only for static keys;
# a placeholder value here is treated as a real (invalid) key.
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
# Lifetime of generated presigned URLs, in seconds. They are re-signed on every
# response, so this only needs to outlive a page render. AWS rejects SigV4
# presigns above 604800 (7 days); larger values are clamped to that.
S3_PRESIGN_EXPIRY_SECONDS=3600
# Jira — create an OAuth 2.0 (3LO) app at developer.atlassian.com. Unlike the
# other integrations the redirect URI is configured here rather than derived
# from BACKEND_URL, so it must match the callback registered on the Atlassian
# app exactly (route: {API_V1_STR}/jira/callback).
JIRA_CLIENT_ID=your_jira_client_id_here
JIRA_CLIENT_SECRET=your_jira_client_secret_here
JIRA_REDIRECT_URI=http://localhost:8000/api/v1/jira/callback
SHOPIFY_API_KEY=your_shopify_key
SHOPIFY_API_SECRET=your_shopify_secret
SHOPIFY_API_VERSION=2025-04
# Slack channel — create an app at api.slack.com/apps. The OAuth redirect URI is
# derived from BACKEND_URL, so add {BACKEND_URL}/api/v1/channels/slack/callback
# to the app's Redirect URLs. Without SLACK_SIGNING_SECRET every incoming Slack
# webhook is rejected with a 403, since signature checks fail closed.
SLACK_CLIENT_ID=your_slack_client_id
SLACK_CLIENT_SECRET=your_slack_client_secret
SLACK_SIGNING_SECRET=your_slack_signing_secret
# CRM lead push (HubSpot / Pipedrive) — create OAuth apps at
# developers.hubspot.com and developers.pipedrive.com. Redirect URIs derive from
# BACKEND_URL: add {BACKEND_URL}/api/v1/crm/hubspot/callback and
# {BACKEND_URL}/api/v1/crm/pipedrive/callback to the respective apps.
HUBSPOT_CLIENT_ID=your_hubspot_client_id
HUBSPOT_CLIENT_SECRET=your_hubspot_client_secret
PIPEDRIVE_CLIENT_ID=your_pipedrive_client_id
PIPEDRIVE_CLIENT_SECRET=your_pipedrive_client_secret
# Meta channels (WhatsApp Cloud API, Messenger, Instagram) — create an app at
# developers.facebook.com. META_WEBHOOK_VERIFY_TOKEN is any random string you pick
# and also enter in the App Dashboard webhook config.
META_APP_ID=your_meta_app_id
META_APP_SECRET=your_meta_app_secret
META_WEBHOOK_VERIFY_TOKEN=any_random_string_you_choose
META_GRAPH_VERSION=v21.0
# Only for cloud-hosted one-click onboarding; leave blank for self-hosting.
# META_CONFIG_ID: WhatsApp Embedded Signup. META_MESSENGER_CONFIG_ID: Facebook
# Login for Business, for connecting a Page (Messenger + Instagram DM).
META_CONFIG_ID=
META_MESSENGER_CONFIG_ID=
# Instagram API with Instagram Login (no Facebook Page needed). These are the
# Instagram app id/secret from the app's Instagram product, NOT the Meta ones.
INSTAGRAM_APP_ID=
INSTAGRAM_APP_SECRET=
# Comma-separated emails allowed to use one-click Meta signup. Empty = everyone.
# Set this while the Meta app is in App Review so the flow can be exercised in
# production without offering customers a login that would fail for them.
SIGNUP_ALLOWED_EMAILS=
VERIFY_SSL_CERTIFICATES=true
# Which addresses the AI base URL, MCP server URLs and ticket database (or its
# SSH host) may point at. These are dialled by this server, so:
# any -> no restriction. Default; lets a self-hosted install reach its
# own network (Ollama on localhost, an internal MCP server, a
# private database).
# public_only -> refuse private, loopback, link-local and reserved addresses.
# Use it when people you don't control can configure these
# (a shared or multi-tenant deployment). Checked on the
# address actually dialled, redirects included; these
# connections then bypass HTTP(S)_PROXY.
OUTBOUND_URL_POLICY=any
# STDIO MCP connectors run a command on this server, typed by an org admin.
# Keep true on your own install; set false wherever people you don't control
# can add connectors (a shared or multi-tenant deployment).
MCP_STDIO_ENABLED=true
# Enhanced Website Knowledge Base Configuration
KB_MAX_DEPTH=5
KB_MAX_LINKS=25
KB_MIN_CONTENT_LENGTH=100
KB_TIMEOUT=30
KB_MAX_RETRIES=3
KB_MAX_WORKERS=5
KB_BATCH_SIZE=5
KB_SOURCE_TIME_LIMIT_SECONDS=1800
KB_PDF_MAX_PAGES=300
# Seconds a single agent run (model + tool calls) may take before it is
# cancelled and the visitor gets an error reply. Raise it if agents with many
# MCP/Shopify tools hit the limit on legitimate answers.
AGENT_RUN_TIMEOUT=90
# agno's own run reporting to api.agno.com. Off unless set to true here; when on,
# it runs inside every agent run and counts against AGENT_RUN_TIMEOUT.
# AGNO_TELEMETRY=true
# AGNO_MONITOR=true
# Tool calls an agent may make in one turn before it has to answer from what it
# already retrieved. Hitting this is not an error — the agent answers without
# further tools. Raise it if agents with many knowledge sources or MCP tools are
# answering from too little context.
AGENT_TOOL_CALL_LIMIT=5
+10
View File
@@ -0,0 +1,10 @@
__pycache__/
*.pyc
.env
firebase-credentials.json
uploads/
# Coverage reports
.coverage
coverage_html/
htmlcov/
app/enterprise/
+16
View File
@@ -0,0 +1,16 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
+117
View File
@@ -0,0 +1,117 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
# Use forward slashes (/) also on windows to provide an os agnostic path
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
# version_path_separator = newline
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration.
+74
View File
@@ -0,0 +1,74 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
# alembic/env.py
import os
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
from app.database import Base
from app.models import Organization, User, Role, AIConfig, Agent, Knowledge, KnowledgeToAgent, AIModelType
from app.core.config import settings
# this is the Alembic Config object, which provides access to the values within the .ini file
config = context.config
# Override sqlalchemy.url with our database URL from settings
config.set_main_option('sqlalchemy.url', settings.DATABASE_URL)
# Interpret the config file for Python logging.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
def run_migrations_offline():
context.configure(
url=config.get_main_option("sqlalchemy.url"),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection,
target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+26
View File
@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,46 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""add_priority_to_knowledge_queue
Revision ID: 013174e15242
Revises: dc827ab30bd4
Create Date: 2025-10-06 14:35:40.640506
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '013174e15242'
down_revision: Union[str, None] = 'dc827ab30bd4'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add priority column with default value 0 and create index
op.add_column('knowledge_queue', sa.Column('priority', sa.Integer(), nullable=False, server_default='0'))
op.create_index(op.f('ix_knowledge_queue_priority'), 'knowledge_queue', ['priority'], unique=False)
def downgrade() -> None:
# Remove index and column
op.drop_index(op.f('ix_knowledge_queue_priority'), table_name='knowledge_queue')
op.drop_column('knowledge_queue', 'priority')
@@ -0,0 +1,42 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""merge guardrails and chat initiation
Revision ID: 0cf34ff05aee
Revises: 2037204bbebf, a1b2c3d4e5f6
Create Date: 2025-10-31 19:08:54.718682
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0cf34ff05aee'
down_revision: Union[str, None] = ('2037204bbebf', 'a1b2c3d4e5f6')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -0,0 +1,86 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""add_teamid_to_agent_sessions
Revision ID: 0cf5fa1db0cb
Revises: 91a148726029
Create Date: 2025-03-21 13:57:55.161703
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0cf5fa1db0cb'
down_revision: Union[str, None] = '91a148726029'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Check if 'ai' schema and agent_sessions table exist before attempting to modify
conn = op.get_bind()
# Check if schema exists
schema_result = conn.execute(sa.text("SELECT schema_name FROM information_schema.schemata WHERE schema_name = 'ai'"))
schema_exists = schema_result.fetchone() is not None
if not schema_exists:
print("Schema 'ai' does not exist, skipping migration")
return
# Check if table exists
table_result = conn.execute(sa.text(
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'ai' AND table_name = 'agent_sessions'"
))
table_exists = table_result.fetchone() is not None
if table_exists:
# Add team_id column to ai.agent_sessions table
op.add_column('agent_sessions',
sa.Column('team_id', sa.UUID(), nullable=True),
schema='ai'
)
else:
print("Table 'ai.agent_sessions' does not exist, skipping migration")
def downgrade() -> None:
# Check if 'ai' schema and agent_sessions table exist before attempting to modify
conn = op.get_bind()
# Check if schema exists
schema_result = conn.execute(sa.text("SELECT schema_name FROM information_schema.schemata WHERE schema_name = 'ai'"))
schema_exists = schema_result.fetchone() is not None
if not schema_exists:
print("Schema 'ai' does not exist, skipping migration rollback")
return
# Check if table exists
table_result = conn.execute(sa.text(
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'ai' AND table_name = 'agent_sessions'"
))
table_exists = table_result.fetchone() is not None
if table_exists:
# Drop team_id column from ai.agent_sessions table
op.drop_column('agent_sessions', 'team_id', schema='ai')
else:
print("Table 'ai.agent_sessions' does not exist, skipping migration rollback")
@@ -0,0 +1,48 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""removed message node specific fields
Revision ID: 0ee9dbc73d9e
Revises: bdaa7a5d6109
Create Date: 2025-07-23 12:19:03.629271
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0ee9dbc73d9e'
down_revision: Union[str, None] = 'bdaa7a5d6109'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('workflow_nodes', 'message_text')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('workflow_nodes', sa.Column('message_text', sa.TEXT(), autoincrement=False, nullable=True))
# ### end Alembic commands ###
@@ -0,0 +1,63 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""added shopify models
Revision ID: 1493b8279a2f
Revises: 520df92643e3
Create Date: 2025-04-10 13:50:54.439561
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '1493b8279a2f'
down_revision: Union[str, None] = '520df92643e3'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('shopify_shops',
sa.Column('id', sa.String(), nullable=False),
sa.Column('shop_domain', sa.String(), nullable=False),
sa.Column('access_token', sa.String(), nullable=True),
sa.Column('scope', sa.String(), nullable=True),
sa.Column('is_installed', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.Column('organization_id', sa.UUID(), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_shopify_shops_shop_domain'), 'shopify_shops', ['shop_domain'], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_shopify_shops_shop_domain'), table_name='shopify_shops')
op.drop_table('shopify_shops')
# ### end Alembic commands ###
@@ -0,0 +1,72 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""added rating table
Revision ID: 186c65ae9406
Revises: 537f2b7d4a7c
Create Date: 2025-03-04 12:18:45.004571
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '186c65ae9406'
down_revision: Union[str, None] = '537f2b7d4a7c'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('ratings',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('session_id', sa.UUID(), nullable=False),
sa.Column('customer_id', sa.UUID(), nullable=True),
sa.Column('agent_id', sa.UUID(), nullable=True),
sa.Column('organization_id', sa.UUID(), nullable=False),
sa.Column('rating', sa.Integer(), nullable=False),
sa.Column('feedback', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['agent_id'], ['agents.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['customer_id'], ['customers.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['session_id'], ['session_to_agents.session_id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_ratings_id'), 'ratings', ['id'], unique=False)
op.alter_column('agents', 'ask_for_rating',
existing_type=sa.BOOLEAN(),
nullable=True,
existing_server_default=sa.text('true'))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('agents', 'ask_for_rating',
existing_type=sa.BOOLEAN(),
nullable=False,
existing_server_default=sa.text('true'))
op.drop_index(op.f('ix_ratings_id'), table_name='ratings')
op.drop_table('ratings')
# ### end Alembic commands ###
@@ -0,0 +1,59 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""add guardrails node type
Revision ID: 2037204bbebf
Revises: 7af9828611e7
Create Date: 2025-10-29 11:47:00.000000
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '2037204bbebf'
down_revision: Union[str, None] = '7af9828611e7'
def upgrade() -> None:
# Add GUARDRAILS to the NodeType enum
op.execute("ALTER TYPE nodetype ADD VALUE IF NOT EXISTS 'GUARDRAILS'")
def downgrade() -> None:
# First, delete or convert any existing GUARDRAILS nodes
# Option 1: Delete GUARDRAILS nodes (uncomment if you want to delete them)
# op.execute("DELETE FROM workflow_nodes WHERE node_type = 'GUARDRAILS'")
# Option 2: Convert GUARDRAILS nodes to ACTION nodes (safer - preserves data)
op.execute("UPDATE workflow_nodes SET node_type = 'ACTION' WHERE node_type = 'GUARDRAILS'")
# Create a new enum without the guardrails value
op.execute("""
CREATE TYPE nodetype_new AS ENUM (
'MESSAGE','LLM','CONDITION','FORM','ACTION','HUMAN_TRANSFER','WAIT','END','LANDING_PAGE','USER_INPUT'
)
""")
# Update the column type to use the new enum
op.execute("ALTER TABLE workflow_nodes ALTER COLUMN node_type TYPE nodetype_new USING node_type::text::nodetype_new")
# Drop the old enum
op.execute("DROP TYPE nodetype")
# Rename the new enum to the original name
op.execute("ALTER TYPE nodetype_new RENAME TO nodetype")
@@ -0,0 +1,42 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""merge multiple heads
Revision ID: 2381319791e8
Revises: add_allowed_attachment_types_001, add_require_token_auth_001
Create Date: 2026-01-08 10:31:54.013991
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '2381319791e8'
down_revision: Union[str, None] = ('add_allowed_attachment_types_001', 'add_require_token_auth_001')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -0,0 +1,44 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""add_workflow_history_column
Revision ID: 2fd826882d08
Revises: 0ee9dbc73d9e
Create Date: 2025-07-24 21:10:23.798939
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '2fd826882d08'
down_revision: Union[str, None] = '0ee9dbc73d9e'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add workflow_history column to store form submissions and workflow interactions
op.add_column('session_to_agents', sa.Column('workflow_history', sa.JSON(), nullable=True))
def downgrade() -> None:
# Remove workflow_history column
op.drop_column('session_to_agents', 'workflow_history')
@@ -0,0 +1,64 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""add jira config
Revision ID: 30caca239191
Revises: fa04f644fc0c
Create Date: 2025-03-06 20:27:48.691533
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '30caca239191'
down_revision: Union[str, None] = 'fa04f644fc0c'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('jira_tokens',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('organization_id', sa.UUID(), nullable=False),
sa.Column('access_token', sa.String(), nullable=False),
sa.Column('refresh_token', sa.String(), nullable=False),
sa.Column('token_type', sa.String(), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('cloud_id', sa.String(), nullable=False),
sa.Column('site_url', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_jira_tokens_id'), 'jira_tokens', ['id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_jira_tokens_id'), table_name='jira_tokens')
op.drop_table('jira_tokens')
# ### end Alembic commands ###
@@ -0,0 +1,46 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""Added replypier AI model
Revision ID: 520df92643e3
Revises: 85364d188f15
Create Date: 2025-03-28 23:27:11.906265
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '520df92643e3'
down_revision: Union[str, None] = '85364d188f15'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# Create a new temporary type with the new value
op.execute("ALTER TYPE aimodeltype ADD VALUE IF NOT EXISTS 'REPLYPIER'")
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# Note: PostgreSQL does not support removing enum values
# We can only create a new enum without the value and replace the old one
pass
# ### end Alembic commands ###
@@ -0,0 +1,58 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""make user org and role nullable
Revision ID: 52e6ca829bb8
Revises: b365b8447598
Create Date: 2024-02-04 18:54:47.823
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '52e6ca829bb8'
down_revision: Union[str, None] = 'b365b8447598'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Make organization_id nullable
op.alter_column('users', 'organization_id',
existing_type=sa.UUID(),
nullable=True)
# Make role_id nullable
op.alter_column('users', 'role_id',
existing_type=sa.INTEGER(),
nullable=True)
def downgrade() -> None:
# Revert organization_id to non-nullable
op.alter_column('users', 'organization_id',
existing_type=sa.UUID(),
nullable=False)
# Revert role_id to non-nullable
op.alter_column('users', 'role_id',
existing_type=sa.INTEGER(),
nullable=False)
@@ -0,0 +1,67 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""added end chat support
Revision ID: 537f2b7d4a7c
Revises: 5b5f936cd9e4
Create Date: 2025-03-04 12:07:37.679734
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '537f2b7d4a7c'
down_revision: Union[str, None] = '5b5f936cd9e4'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create the enum type first
end_chat_reason_type = postgresql.ENUM(
'ISSUE_RESOLVED',
'CUSTOMER_REQUEST',
'CONFIRMATION_RECEIVED',
'FAREWELL',
'THANK_YOU',
'NATURAL_CONCLUSION',
'TASK_COMPLETED',
name='endchatreasontype'
)
end_chat_reason_type.create(op.get_bind())
# Add columns
op.add_column('agents', sa.Column('ask_for_rating', sa.Boolean(), server_default='true', nullable=False))
op.add_column('session_to_agents', sa.Column('end_chat_reason', postgresql.ENUM('ISSUE_RESOLVED', 'CUSTOMER_REQUEST', 'CONFIRMATION_RECEIVED', 'FAREWELL', 'THANK_YOU', 'NATURAL_CONCLUSION', 'TASK_COMPLETED', name='endchatreasontype'), nullable=True))
op.add_column('session_to_agents', sa.Column('end_chat_description', sa.String(), nullable=True))
op.drop_column('session_to_agents', 'closed_at')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('session_to_agents', sa.Column('closed_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True))
op.drop_column('session_to_agents', 'end_chat_description')
op.drop_column('session_to_agents', 'end_chat_reason')
op.drop_column('agents', 'ask_for_rating')
# Drop the enum type
op.execute('DROP TYPE endchatreasontype')
@@ -0,0 +1,48 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""Role remove name alone as unique
Revision ID: 5b5f936cd9e4
Revises: 52e6ca829bb8
Create Date: 2025-02-08 07:36:33.283138
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '5b5f936cd9e4'
down_revision: Union[str, None] = '52e6ca829bb8'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint('roles_name_key', 'roles', type_='unique')
op.create_unique_constraint('uq_role_name_org', 'roles', ['name', 'organization_id'])
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint('uq_role_name_org', 'roles', type_='unique')
op.create_unique_constraint('roles_name_key', 'roles', ['name'])
# ### end Alembic commands ###
@@ -0,0 +1,84 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""add_mcp_tools_tables
Revision ID: 621095beb234
Revises: 7991b4fc00d8
Create Date: 2025-07-27 16:45:37.520270
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '621095beb234'
down_revision: Union[str, None] = '7991b4fc00d8'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('mcp_tools',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('transport_type', sa.Enum('STDIO', 'SSE', 'HTTP', name='mcptransporttype'), nullable=False),
sa.Column('enabled', sa.Boolean(), nullable=False),
sa.Column('command', sa.String(), nullable=True),
sa.Column('args', postgresql.JSON(astext_type=sa.Text()), nullable=True),
sa.Column('env_vars', postgresql.JSON(astext_type=sa.Text()), nullable=True),
sa.Column('url', sa.String(), nullable=True),
sa.Column('headers', postgresql.JSON(astext_type=sa.Text()), nullable=True),
sa.Column('timeout', sa.Integer(), nullable=True),
sa.Column('sse_read_timeout', sa.Integer(), nullable=True),
sa.Column('terminate_on_close', sa.Boolean(), nullable=True),
sa.Column('organization_id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_mcp_tools_id'), 'mcp_tools', ['id'], unique=False)
op.create_table('mcp_tool_to_agent',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('mcp_tool_id', sa.Integer(), nullable=False),
sa.Column('agent_id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['agent_id'], ['agents.id'], ),
sa.ForeignKeyConstraint(['mcp_tool_id'], ['mcp_tools.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_mcp_tool_to_agent_id'), 'mcp_tool_to_agent', ['id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_mcp_tool_to_agent_id'), table_name='mcp_tool_to_agent')
op.drop_table('mcp_tool_to_agent')
op.drop_index(op.f('ix_mcp_tools_id'), table_name='mcp_tools')
op.drop_table('mcp_tools')
# ### end Alembic commands ###
@@ -0,0 +1,70 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""add chat_style and widget_position to agent_customizations
Revision ID: 6885c9f36712
Revises: 9ce623009955
Create Date: 2025-01-27 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '6885c9f36712'
down_revision: Union[str, None] = '9ce623009955'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create ChatStyle enum
chatstyle_enum = sa.Enum('CHATBOT', 'ASK_ANYTHING', name='chatstyle')
chatstyle_enum.create(op.get_bind())
# Create WidgetPosition enum
widgetposition_enum = sa.Enum('FLOATING', 'FIXED', name='widgetposition')
widgetposition_enum.create(op.get_bind())
# Add columns to agent_customizations table as nullable first
op.add_column('agent_customizations',
sa.Column('chat_style', chatstyle_enum, nullable=True)
)
op.add_column('agent_customizations',
sa.Column('widget_position', widgetposition_enum, nullable=True)
)
# Update existing rows with default values
op.execute("UPDATE agent_customizations SET chat_style = 'CHATBOT' WHERE chat_style IS NULL")
op.execute("UPDATE agent_customizations SET widget_position = 'FLOATING' WHERE widget_position IS NULL")
# Now make the columns NOT NULL
op.alter_column('agent_customizations', 'chat_style', nullable=False)
op.alter_column('agent_customizations', 'widget_position', nullable=False)
def downgrade() -> None:
# Drop columns
op.drop_column('agent_customizations', 'widget_position')
op.drop_column('agent_customizations', 'chat_style')
# Drop enums
sa.Enum(name='widgetposition').drop(op.get_bind())
sa.Enum(name='chatstyle').drop(op.get_bind())
@@ -0,0 +1,51 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""added wlecome header and title
Revision ID: 7991b4fc00d8
Revises: 6885c9f36712
Create Date: 2025-07-27 16:11:49.392307
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '7991b4fc00d8'
down_revision: Union[str, None] = '6885c9f36712'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('agent_customizations', sa.Column('welcome_title', sa.String(), nullable=True))
op.add_column('agent_customizations', sa.Column('welcome_subtitle', sa.String(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('agent_customizations', 'welcome_subtitle')
op.drop_column('agent_customizations', 'welcome_title')
# ### end Alembic commands ###
@@ -0,0 +1,46 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""Add oauth_state fields to shopify_shops
Revision ID: 7af9828611e7
Revises: d9a4b9902f80
Create Date: 2025-10-28 20:28:32.936719
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '7af9828611e7'
down_revision: Union[str, None] = 'd9a4b9902f80'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add oauth_state and oauth_state_expiry columns to shopify_shops table
op.add_column('shopify_shops', sa.Column('oauth_state', sa.String(), nullable=True))
op.add_column('shopify_shops', sa.Column('oauth_state_expiry', sa.DateTime(), nullable=True))
def downgrade() -> None:
# Remove oauth_state and oauth_state_expiry columns from shopify_shops table
op.drop_column('shopify_shops', 'oauth_state_expiry')
op.drop_column('shopify_shops', 'oauth_state')
@@ -0,0 +1,131 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""Workflow tables added
Revision ID: 7fd0447b3d45
Revises: e252efacd083
Create Date: 2025-07-09 11:12:34.104222
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '7fd0447b3d45'
down_revision: Union[str, None] = 'e252efacd083'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('workflows',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('status', sa.Enum('DRAFT', 'PUBLISHED', 'ARCHIVED', name='workflowstatus'), nullable=True),
sa.Column('version', sa.Integer(), nullable=True),
sa.Column('is_template', sa.Boolean(), nullable=True),
sa.Column('default_language', sa.String(length=10), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.Column('created_by', sa.UUID(), nullable=True),
sa.Column('organization_id', sa.UUID(), nullable=True),
sa.Column('agent_id', sa.UUID(), nullable=True),
sa.Column('canvas_data', sa.JSON(), nullable=True),
sa.Column('settings', sa.JSON(), nullable=True),
sa.ForeignKeyConstraint(['agent_id'], ['agents.id'], ),
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('workflow_nodes',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('workflow_id', sa.UUID(), nullable=False),
sa.Column('node_type', sa.Enum('MESSAGE', 'LLM', 'CONDITION', 'FORM', 'ACTION', 'HUMAN_TRANSFER', 'WAIT', 'END', name='nodetype'), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('position_x', sa.Float(), nullable=True),
sa.Column('position_y', sa.Float(), nullable=True),
sa.Column('config', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('message_text', sa.Text(), nullable=True),
sa.Column('system_prompt', sa.Text(), nullable=True),
sa.Column('temperature', sa.Float(), nullable=True),
sa.Column('model_id', sa.Integer(), nullable=True),
sa.Column('form_fields', sa.JSON(), nullable=True),
sa.Column('condition_expression', sa.Text(), nullable=True),
sa.Column('action_type', sa.String(), nullable=True),
sa.Column('action_config', sa.JSON(), nullable=True),
sa.Column('transfer_rules', sa.JSON(), nullable=True),
sa.Column('wait_duration', sa.Integer(), nullable=True),
sa.Column('wait_until_condition', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['model_id'], ['ai_configs.id'], ),
sa.ForeignKeyConstraint(['workflow_id'], ['workflows.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('workflow_connections',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('workflow_id', sa.UUID(), nullable=False),
sa.Column('source_node_id', sa.UUID(), nullable=False),
sa.Column('target_node_id', sa.UUID(), nullable=False),
sa.Column('label', sa.String(length=100), nullable=True),
sa.Column('condition', sa.Text(), nullable=True),
sa.Column('priority', sa.Integer(), nullable=True),
sa.Column('connection_metadata', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['source_node_id'], ['workflow_nodes.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['target_node_id'], ['workflow_nodes.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['workflow_id'], ['workflows.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.add_column('agents', sa.Column('use_workflow', sa.Boolean(), nullable=True))
op.add_column('agents', sa.Column('active_workflow_id', sa.UUID(), nullable=True))
op.create_foreign_key(None, 'agents', 'workflows', ['active_workflow_id'], ['id'])
op.add_column('session_to_agents', sa.Column('workflow_id', sa.UUID(), nullable=True))
op.add_column('session_to_agents', sa.Column('current_node_id', sa.UUID(), nullable=True))
op.add_column('session_to_agents', sa.Column('workflow_state', sa.JSON(), nullable=True))
op.create_foreign_key(None, 'session_to_agents', 'workflow_nodes', ['current_node_id'], ['id'], ondelete='SET NULL')
op.create_foreign_key(None, 'session_to_agents', 'workflows', ['workflow_id'], ['id'], ondelete='SET NULL')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'session_to_agents', type_='foreignkey')
op.drop_constraint(None, 'session_to_agents', type_='foreignkey')
op.drop_column('session_to_agents', 'workflow_state')
op.drop_column('session_to_agents', 'current_node_id')
op.drop_column('session_to_agents', 'workflow_id')
op.drop_constraint(None, 'agents', type_='foreignkey')
op.drop_column('agents', 'active_workflow_id')
op.drop_column('agents', 'use_workflow')
op.drop_table('workflow_connections')
op.drop_table('workflow_nodes')
op.drop_table('workflows')
# ### end Alembic commands ###
@@ -0,0 +1,42 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""merge heads
Revision ID: 85364d188f15
Revises: 0cf5fa1db0cb, 89a4f4898aa4
Create Date: 2025-03-22 20:43:28.206404
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '85364d188f15'
down_revision: Union[str, None] = ('0cf5fa1db0cb', '89a4f4898aa4')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -0,0 +1,64 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""change datetime to timestamp with timezone
Revision ID: 89a4f4898aa4
Revises: 91a148726029
Create Date: 2024-03-21 19:18:36.159906
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '89a4f4898aa4'
down_revision: Union[str, None] = '91a148726029'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Change columns from DateTime to TIMESTAMP WITH TIME ZONE in chat_history table
op.alter_column('chat_history', 'created_at',
existing_type=sa.DateTime(),
type_=sa.TIMESTAMP(timezone=True),
existing_nullable=True,
existing_server_default=sa.text('now()'))
op.alter_column('chat_history', 'updated_at',
existing_type=sa.DateTime(),
type_=sa.TIMESTAMP(timezone=True),
existing_nullable=True,
existing_server_default=sa.text('now()'))
def downgrade() -> None:
# Change columns back to DateTime in chat_history table
op.alter_column('chat_history', 'created_at',
existing_type=sa.TIMESTAMP(timezone=True),
type_=sa.DateTime(),
existing_nullable=True,
existing_server_default=sa.text('now()'))
op.alter_column('chat_history', 'updated_at',
existing_type=sa.TIMESTAMP(timezone=True),
type_=sa.DateTime(),
existing_nullable=True,
existing_server_default=sa.text('now()'))
@@ -0,0 +1,48 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""make_knowledge_queue_user_id_nullable
Revision ID: 910bfe03518b
Revises: 0cf34ff05aee
Create Date: 2025-11-04 17:18:35.904635
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '910bfe03518b'
down_revision: Union[str, None] = '0cf34ff05aee'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Make user_id nullable in knowledge_queue table
op.alter_column('knowledge_queue', 'user_id',
existing_type=sa.UUID(),
nullable=True)
def downgrade() -> None:
# Revert user_id to not nullable
op.alter_column('knowledge_queue', 'user_id',
existing_type=sa.UUID(),
nullable=False)
@@ -0,0 +1,87 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""add extra_data to agent_sessions
Revision ID: 91a148726029
Revises: f3dad24474e0
Create Date: 2024-03-21 10:57:23.123456
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '91a148726029'
down_revision: Union[str, None] = 'f3dad24474e0'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Check if 'ai' schema and agent_sessions table exist before attempting to modify
conn = op.get_bind()
# Check if schema exists
schema_result = conn.execute(sa.text("SELECT schema_name FROM information_schema.schemata WHERE schema_name = 'ai'"))
schema_exists = schema_result.fetchone() is not None
if not schema_exists:
print("Schema 'ai' does not exist, skipping migration")
return
# Check if table exists
table_result = conn.execute(sa.text(
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'ai' AND table_name = 'agent_sessions'"
))
table_exists = table_result.fetchone() is not None
if table_exists:
# Add extra_data column to ai.agent_sessions table
op.add_column('agent_sessions',
sa.Column('extra_data', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
schema='ai'
)
else:
print("Table 'ai.agent_sessions' does not exist, skipping migration")
def downgrade() -> None:
# Check if 'ai' schema and agent_sessions table exist before attempting to modify
conn = op.get_bind()
# Check if schema exists
schema_result = conn.execute(sa.text("SELECT schema_name FROM information_schema.schemata WHERE schema_name = 'ai'"))
schema_exists = schema_result.fetchone() is not None
if not schema_exists:
print("Schema 'ai' does not exist, skipping migration rollback")
return
# Check if table exists
table_result = conn.execute(sa.text(
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'ai' AND table_name = 'agent_sessions'"
))
table_exists = table_result.fetchone() is not None
if table_exists:
# Drop extra_data column from ai.agent_sessions table
op.drop_column('agent_sessions', 'extra_data', schema='ai')
else:
print("Table 'ai.agent_sessions' does not exist, skipping migration rollback")
@@ -0,0 +1,61 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""Add agent shopify config with shop relation
Revision ID: 9a01e6b653b2
Revises: 1493b8279a2f
Create Date: 2025-04-11 13:43:05.945125
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '9a01e6b653b2'
down_revision: Union[str, None] = '1493b8279a2f'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('agent_shopify_configs',
sa.Column('id', sa.String(), nullable=False),
sa.Column('agent_id', sa.String(), nullable=False),
sa.Column('shop_id', sa.String(), nullable=True),
sa.Column('enabled', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['shop_id'], ['shopify_shops.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_agent_shopify_configs_agent_id'), 'agent_shopify_configs', ['agent_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_agent_shopify_configs_agent_id'), table_name='agent_shopify_configs')
op.drop_table('agent_shopify_configs')
# ### end Alembic commands ###
@@ -0,0 +1,60 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""added new node type user input
Revision ID: 9ce623009955
Revises: 2fd826882d08
Create Date: 2025-07-25 13:59:26.571283
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '9ce623009955'
down_revision: Union[str, None] = '2fd826882d08'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.execute("ALTER TYPE nodetype ADD VALUE IF NOT EXISTS 'USER_INPUT'")
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# Create a new enum without the landing_page value
op.execute("""
CREATE TYPE nodetype_new AS ENUM (
'MESSAGE','LLM','CONDITION','FORM','ACTION','HUMAN_TRANSFER','WAIT','END','LANDING_PAGE'
)
""")
# Update the column type to use the new enum
op.execute("ALTER TABLE workflow_nodes ALTER COLUMN node_type TYPE nodetype_new USING node_type::text::nodetype_new")
# Drop the old enum
op.execute("DROP TYPE nodetype")
# Rename the new enum to the original name
op.execute("ALTER TYPE nodetype_new RENAME TO nodetype")
# ### end Alembic commands ###
@@ -0,0 +1,47 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""add chat initiation messages
Revision ID: a1b2c3d4e5f6
Revises: 7af9828611e7
Create Date: 2025-10-29 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'a1b2c3d4e5f6'
down_revision: Union[str, None] = '7af9828611e7'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('agent_customizations', sa.Column('chat_initiation_messages', postgresql.JSON(astext_type=sa.Text()), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('agent_customizations', 'chat_initiation_messages')
# ### end Alembic commands ###
@@ -0,0 +1,26 @@
"""merge ai-ticketing + agent-queue heads
Revision ID: a1dbf557bbb0
Revises: add_agent_tkt_toggle_001, b7c4e91a2d38
Create Date: 2026-07-20 11:02:56.904328
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'a1dbf557bbb0'
down_revision: Union[str, None] = ('add_agent_tkt_toggle_001', 'b7c4e91a2d38')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -0,0 +1,42 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""merge crm and chat-session-idx heads
Revision ID: a8efc6b31aa8
Revises: add_chat_session_idx_001, add_crm_tables_001
Create Date: 2026-07-28 18:55:32.388594
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'a8efc6b31aa8'
down_revision: Union[str, None] = ('add_chat_session_idx_001', 'add_crm_tables_001')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -0,0 +1,63 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""add_file_attachments_table
Revision ID: abc123456789
Revises: 0cf34ff05aee
Create Date: 2025-10-30 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'abc123456789'
down_revision: Union[str, None] = '0cf34ff05aee'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create file_attachments table
op.create_table('file_attachments',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('file_url', sa.String(), nullable=False),
sa.Column('filename', sa.String(), nullable=False),
sa.Column('content_type', sa.String(), nullable=False),
sa.Column('file_size', sa.BigInteger(), nullable=False),
sa.Column('chat_history_id', sa.Integer(), nullable=True),
sa.Column('organization_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('uploaded_by_user_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('uploaded_by_customer_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['chat_history_id'], ['chat_history.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['uploaded_by_user_id'], ['users.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['uploaded_by_customer_id'], ['customers.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_file_attachments_id'), 'file_attachments', ['id'], unique=False)
def downgrade() -> None:
# Drop the table and index
op.drop_index(op.f('ix_file_attachments_id'), table_name='file_attachments')
op.drop_table('file_attachments')
@@ -0,0 +1,45 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""add agents.ai_replies_enabled (human-only agents)
Revision ID: add_agent_ai_replies_001
Revises: purge_mangled_crawl_pages_001
Create Date: 2026-08-02
Off means the AI never answers on this agent: every chat is queued for the team
from the customer's first message. Defaults on, so existing agents keep
answering exactly as before.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'add_agent_ai_replies_001'
down_revision = 'purge_mangled_crawl_pages_001'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
'agents',
sa.Column('ai_replies_enabled', sa.Boolean(), server_default=sa.text('true'), nullable=False),
)
def downgrade() -> None:
op.drop_column('agents', 'ai_replies_enabled')
@@ -0,0 +1,51 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""add agents.guardrail_prompt and agents.guardrail_enabled
Revision ID: add_agent_guardrail_prompt_001
Revises: add_guardrails_layer_001
Create Date: 2026-07-31
Makes the agent's scope rule tenant-editable. A code-owned topic list is a
guess about what a business is NOT — the shipped wording refused maths problems
for a maths tutor and algorithm questions for a coding bootcamp, i.e. their core
use case. guardrail_prompt NULL means "use the platform default", so the default
wording can still be improved centrally without rewriting existing rows.
guardrail_enabled defaults true so every existing agent keeps its protection.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'add_agent_guardrail_prompt_001'
down_revision = 'add_guardrails_layer_001'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('agents', sa.Column('guardrail_prompt', sa.Text(), nullable=True))
op.add_column(
'agents',
sa.Column('guardrail_enabled', sa.Boolean(),
server_default=sa.text('true'), nullable=False),
)
def downgrade() -> None:
op.drop_column('agents', 'guardrail_enabled')
op.drop_column('agents', 'guardrail_prompt')
@@ -0,0 +1,45 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""add agents.ticketing_enabled (per-agent native ticketing toggle)
Revision ID: add_agent_tkt_toggle_001
Revises: add_tkt_db_ssh_001
Create Date: 2026-07-17
Per-agent switch for native AI ticketing. Defaults on so paid orgs get
ticketing on every agent out of the box; the org's plan gate still applies on
top of it.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'add_agent_tkt_toggle_001'
down_revision = 'add_tkt_db_ssh_001'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
'agents',
sa.Column('ticketing_enabled', sa.Boolean(), server_default=sa.text('true'), nullable=False),
)
def downgrade() -> None:
op.drop_column('agents', 'ticketing_enabled')
@@ -0,0 +1,46 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""add agent_customizations.show_ai_disclaimer
Revision ID: add_ai_disclaimer_001
Revises: add_agent_guardrail_prompt_001
Create Date: 2026-07-31
Controls the "AI can make mistakes" line in the widget footer. Defaults true so
existing widgets disclose by default; operators who show the disclosure
elsewhere on their site can switch it off.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'add_ai_disclaimer_001'
down_revision = 'add_agent_guardrail_prompt_001'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
'agent_customizations',
sa.Column('show_ai_disclaimer', sa.Boolean(),
server_default=sa.text('true'), nullable=False),
)
def downgrade() -> None:
op.drop_column('agent_customizations', 'show_ai_disclaimer')
@@ -0,0 +1,44 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""Add allow_attachments to agents table
Revision ID: add_allow_attachments_001
Revises: abc123456789, 910bfe03518b
Create Date: 2025-11-08 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'add_allow_attachments_001'
down_revision: Union[str, Sequence[str]] = ('abc123456789', '910bfe03518b')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('agents',
sa.Column('allow_attachments', sa.Boolean(), nullable=False, server_default='false')
)
def downgrade() -> None:
op.drop_column('agents', 'allow_attachments')
@@ -0,0 +1,57 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""add agent_customizations.allow_new_chat
Revision ID: add_allow_new_chat_001
Revises: repair_agent_role_seed_001
Create Date: 2026-08-09
Shows a "New chat" control in the widget so a visitor can abandon the current
conversation and start a clean one. Defaults false: starting a new chat closes
the session, which would cut off a human agent mid-handover, so operators opt in.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'add_allow_new_chat_001'
down_revision = 'repair_agent_role_seed_001'
branch_labels = None
depends_on = None
def _has_column() -> bool:
"""Development databases are shared between feature branches, so the column can
already exist by the time this runs. Adding it twice would abort the upgrade."""
inspector = sa.inspect(op.get_bind())
return any(c['name'] == 'allow_new_chat'
for c in inspector.get_columns('agent_customizations'))
def upgrade() -> None:
if _has_column():
return
op.add_column(
'agent_customizations',
sa.Column('allow_new_chat', sa.Boolean(),
server_default=sa.text('false'), nullable=False),
)
def downgrade() -> None:
if _has_column():
op.drop_column('agent_customizations', 'allow_new_chat')
@@ -0,0 +1,45 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""Add allowed_attachment_types to agents table
Revision ID: add_allowed_attachment_types_001
Revises: add_slack_integration_001
Create Date: 2024-12-17
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'add_allowed_attachment_types_001'
down_revision: Union[str, Sequence[str]] = 'add_slack_integration_001'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add allowed_attachment_types column as JSON
op.add_column('agents',
sa.Column('allowed_attachment_types', sa.JSON(), nullable=True)
)
def downgrade() -> None:
op.drop_column('agents', 'allowed_attachment_types')
@@ -0,0 +1,126 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""Add omni-channel tables and session channel column
Revision ID: add_channel_tables_001
Revises: e5f6a7b8c9d0
Create Date: 2026-07-10 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'add_channel_tables_001'
down_revision: Union[str, None] = 'e5f6a7b8c9d0'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'channel_accounts',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('organization_id', sa.UUID(), nullable=False),
sa.Column('channel_type', sa.String(), nullable=False),
sa.Column('external_account_id', sa.String(), nullable=False),
sa.Column('display_name', sa.String(), nullable=True),
sa.Column('encrypted_credentials', sa.Text(), nullable=False),
sa.Column('webhook_secret', sa.String(), nullable=False),
sa.Column('settings', sa.JSON(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('channel_type', 'external_account_id', name='uq_channel_account_external'),
)
op.create_index('ix_channel_accounts_organization_id', 'channel_accounts', ['organization_id'])
op.create_index('ix_channel_accounts_channel_type', 'channel_accounts', ['channel_type'])
op.create_table(
'channel_conversations',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('channel_account_id', sa.UUID(), nullable=False),
sa.Column('channel_type', sa.String(), nullable=False),
sa.Column('external_conversation_id', sa.String(), nullable=False),
sa.Column('external_user_id', sa.String(), nullable=False),
sa.Column('session_id', sa.UUID(), nullable=False),
sa.Column('organization_id', sa.UUID(), nullable=False),
sa.Column('agent_id', sa.UUID(), nullable=True),
sa.Column('customer_id', sa.UUID(), nullable=True),
sa.Column('last_inbound_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('extra', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['channel_account_id'], ['channel_accounts.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['session_id'], ['session_to_agents.session_id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['agent_id'], ['agents.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['customer_id'], ['customers.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint(
'channel_account_id', 'external_conversation_id', 'session_id',
name='uq_channel_conversation_session'
),
)
op.create_index('ix_channel_conversations_channel_account_id', 'channel_conversations', ['channel_account_id'])
op.create_index('ix_channel_conversations_external_conversation_id', 'channel_conversations', ['external_conversation_id'])
op.create_index('ix_channel_conversations_session_id', 'channel_conversations', ['session_id'])
op.create_table(
'agent_channel_configs',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('agent_id', sa.UUID(), nullable=False),
sa.Column('channel_account_id', sa.UUID(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['agent_id'], ['agents.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['channel_account_id'], ['channel_accounts.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('channel_account_id', name='uq_agent_channel_config_account'),
)
op.create_index('ix_agent_channel_configs_agent_id', 'agent_channel_configs', ['agent_id'])
op.create_index('ix_agent_channel_configs_channel_account_id', 'agent_channel_configs', ['channel_account_id'])
op.add_column(
'session_to_agents',
sa.Column('channel', sa.String(), nullable=False, server_default='web')
)
op.create_index('ix_session_to_agents_channel', 'session_to_agents', ['channel'])
def downgrade() -> None:
op.drop_index('ix_session_to_agents_channel', table_name='session_to_agents')
op.drop_column('session_to_agents', 'channel')
op.drop_index('ix_agent_channel_configs_channel_account_id', table_name='agent_channel_configs')
op.drop_index('ix_agent_channel_configs_agent_id', table_name='agent_channel_configs')
op.drop_table('agent_channel_configs')
op.drop_index('ix_channel_conversations_session_id', table_name='channel_conversations')
op.drop_index('ix_channel_conversations_external_conversation_id', table_name='channel_conversations')
op.drop_index('ix_channel_conversations_channel_account_id', table_name='channel_conversations')
op.drop_table('channel_conversations')
op.drop_index('ix_channel_accounts_channel_type', table_name='channel_accounts')
op.drop_index('ix_channel_accounts_organization_id', table_name='channel_accounts')
op.drop_table('channel_accounts')
@@ -0,0 +1,50 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""index chat_history on (session_id, created_at)
Revision ID: add_chat_session_idx_001
Revises: hc_img_paths_001
Create Date: 2026-07-22
chat_history had no index on session_id — only the primary key — so every lookup of
a conversation's messages was a sequential scan. The columns match the ordering
every reader now uses, (created_at, id), so the inbox previews and
get_session_history are both answered from the index alone.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'add_chat_session_idx_001'
down_revision = 'hc_img_paths_001'
branch_labels = None
depends_on = None
INDEX_NAME = 'ix_chat_history_session_created'
def upgrade() -> None:
op.create_index(
INDEX_NAME,
'chat_history',
['session_id', sa.text('created_at DESC'), sa.text('id DESC')],
unique=False,
)
def downgrade() -> None:
op.drop_index(INDEX_NAME, table_name='chat_history')
@@ -0,0 +1,61 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""add crm_customer_syncs (per-person CRM link)
Revision ID: add_crm_customer_syncs_001
Revises: a8efc6b31aa8
Create Date: 2026-07-28 20:40:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'add_crm_customer_syncs_001'
down_revision: Union[str, None] = 'a8efc6b31aa8'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'crm_customer_syncs',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('organization_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('customer_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('provider', sa.String(length=32), nullable=False),
sa.Column('contact_id', sa.String(), nullable=True),
sa.Column('secondary_id', sa.String(), nullable=True),
sa.Column('record_url', sa.String(), nullable=True),
sa.Column('synced_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['customer_id'], ['customers.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('customer_id', 'provider', name='uq_crm_customer_sync_customer_provider'),
)
op.create_index('ix_crm_customer_syncs_organization_id', 'crm_customer_syncs', ['organization_id'])
op.create_index('ix_crm_customer_syncs_customer_id', 'crm_customer_syncs', ['customer_id'])
def downgrade() -> None:
op.drop_index('ix_crm_customer_syncs_customer_id', table_name='crm_customer_syncs')
op.drop_index('ix_crm_customer_syncs_organization_id', table_name='crm_customer_syncs')
op.drop_table('crm_customer_syncs')
@@ -0,0 +1,97 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""add crm_connections and crm_sync_jobs (CRM lead push)
Revision ID: add_crm_tables_001
Revises: crm_sync_varchar_001
Create Date: 2026-07-22 13:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'add_crm_tables_001'
down_revision: Union[str, None] = 'crm_sync_varchar_001'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'crm_connections',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('organization_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('provider', sa.String(length=32), nullable=False),
sa.Column('external_account_id', sa.String(), nullable=False),
sa.Column('display_name', sa.String(), nullable=True),
sa.Column('encrypted_credentials', sa.Text(), nullable=False),
sa.Column('status', sa.String(length=16), nullable=False, server_default='active'),
sa.Column('last_error', sa.Text(), nullable=True),
sa.Column('access_token_expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('refresh_token_expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('last_refreshed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('connected_by_user_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['connected_by_user_id'], ['users.id']),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('organization_id', 'provider', name='uq_crm_connection_org_provider'),
sa.UniqueConstraint('provider', 'external_account_id', name='uq_crm_connection_external'),
)
op.create_index('ix_crm_connections_organization_id', 'crm_connections', ['organization_id'])
op.create_index('ix_crm_connections_provider', 'crm_connections', ['provider'])
op.create_table(
'crm_sync_jobs',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('organization_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('lead_response_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('agent_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('provider', sa.String(length=32), nullable=False),
sa.Column('connection_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('status', sa.String(length=16), nullable=False, server_default='pending'),
sa.Column('attempts', sa.Integer(), nullable=False, server_default='0'),
sa.Column('next_attempt_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
sa.Column('started_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('last_error', sa.Text(), nullable=True),
sa.Column('result', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['lead_response_id'], ['lead_capture_responses.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['agent_id'], ['agents.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['connection_id'], ['crm_connections.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('lead_response_id', 'provider', name='uq_crm_sync_job_lead_provider'),
)
op.create_index('ix_crm_sync_jobs_organization_id', 'crm_sync_jobs', ['organization_id'])
op.create_index('ix_crm_sync_jobs_claim', 'crm_sync_jobs', ['status', 'next_attempt_at'])
def downgrade() -> None:
op.drop_index('ix_crm_sync_jobs_claim', table_name='crm_sync_jobs')
op.drop_index('ix_crm_sync_jobs_organization_id', table_name='crm_sync_jobs')
op.drop_table('crm_sync_jobs')
op.drop_index('ix_crm_connections_provider', table_name='crm_connections')
op.drop_index('ix_crm_connections_organization_id', table_name='crm_connections')
op.drop_table('crm_connections')
@@ -0,0 +1,56 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
customers.phone: a second identity key beside email, normalized E.164 with a
leading '+'. Phone-bearing channels (WhatsApp, SMS, Telegram share-contact)
resolve people by it, so it must be unique per org — but only where present,
hence a partial unique index rather than a UniqueConstraint (rows without a
phone are the overwhelming majority and must never collide). The index is
declared in the model's __table_args__ as well as created here — so
autogenerate never sees it as unknown and drops it, and so the sqlite test
schema enforces the same uniqueness Postgres does. Keep the two in step.
No backfill: WhatsApp/SMS aren't live yet, so no rows carry a phone anywhere
to backfill from.
Revision ID: add_customer_phone_001
Revises: add_hc_faq_feedback_001
"""
import sqlalchemy as sa
from alembic import op
revision = "add_customer_phone_001"
down_revision = "add_hc_faq_feedback_001"
branch_labels = None
depends_on = None
INDEX = "uix_customer_phone_org"
def upgrade() -> None:
op.add_column("customers", sa.Column("phone", sa.String(), nullable=True))
op.create_index(
INDEX,
"customers",
["organization_id", "phone"],
unique=True,
postgresql_where=sa.text("phone IS NOT NULL"),
)
def downgrade() -> None:
op.drop_index(INDEX, table_name="customers")
op.drop_column("customers", "phone")
@@ -0,0 +1,40 @@
"""Row-level scoping for the ticket DB connector
Revision ID: add_tkt_row_scope_001
Revises: a1dbf557bbb0
Create Date: 2026-07-20
Adds the per-table customer-column map that restricts a connector's queries to
the ticket customer's own rows, and which customer value fills the predicate.
Existing connectors get NULL/'email' — no scoping until an org configures it,
so behaviour is unchanged on upgrade.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSON
revision = "add_tkt_row_scope_001"
down_revision = "a1dbf557bbb0"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"ticket_db_connectors",
sa.Column("row_scope", JSON(), nullable=True),
)
op.add_column(
"ticket_db_connectors",
sa.Column(
"row_scope_key",
sa.String(length=20),
nullable=False,
server_default="email",
),
)
def downgrade() -> None:
op.drop_column("ticket_db_connectors", "row_scope_key")
op.drop_column("ticket_db_connectors", "row_scope")
@@ -0,0 +1,27 @@
"""Per-article SEO overrides on FAQs
Revision ID: add_faq_seo_001
Revises: rel_hc_uploads_001
Create Date: 2026-07-25
Adds nullable meta_title / meta_description to faqs so an org can override the
public article page's <title> and meta description instead of always deriving
them from the question and answer excerpt. NULL keeps today's derived behaviour.
"""
from alembic import op
import sqlalchemy as sa
revision = "add_faq_seo_001"
down_revision = "rel_hc_uploads_001"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("faqs", sa.Column("meta_title", sa.String(length=120), nullable=True))
op.add_column("faqs", sa.Column("meta_description", sa.String(length=300), nullable=True))
def downgrade() -> None:
op.drop_column("faqs", "meta_description")
op.drop_column("faqs", "meta_title")
@@ -0,0 +1,77 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""add guardrail_events table and agents.topic_scope
Revision ID: add_guardrails_layer_001
Revises: add_crm_customer_syncs_001
Create Date: 2026-07-30
Global guardrail layer for customer-facing agents. guardrail_events records
every trigger (injection signals, blocks, prompt-leak replacements, scope
refusals) so abuse is measurable and false positives reviewable before any
rule is promoted from counting to blocking. agents.topic_scope is the optional
per-agent remit override for the code-owned policy's topic-scope line; null
falls back to the agent description or the organization name/domain.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'add_guardrails_layer_001'
down_revision = 'add_crm_customer_syncs_001'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
'guardrail_events',
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column('organization_id', postgresql.UUID(as_uuid=True),
sa.ForeignKey('organizations.id', ondelete='CASCADE'), nullable=True),
sa.Column('agent_id', postgresql.UUID(as_uuid=True),
sa.ForeignKey('agents.id', ondelete='SET NULL'), nullable=True),
# No FK: a help-center /ask event has no chat session.
sa.Column('session_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('surface', sa.String(20), nullable=False),
sa.Column('layer', sa.String(16), nullable=False),
sa.Column('rule', sa.String(64), nullable=False),
sa.Column('action', sa.String(16), nullable=False),
sa.Column('score', sa.Float(), nullable=True),
# Rule ids / short match tokens only — never raw visitor text.
sa.Column('matched', sa.JSON(), nullable=True),
sa.Column('char_len', sa.Integer(), nullable=True),
# Encrypted at rest (EncryptedText decorates a Text column).
sa.Column('excerpt', sa.Text(), nullable=True),
sa.Column('reviewed', sa.Boolean(), server_default=sa.text('false'), nullable=False),
sa.Column('false_positive', sa.Boolean(), server_default=sa.text('false'), nullable=False),
sa.Column('created_at', sa.TIMESTAMP(timezone=True), server_default=sa.func.now()),
)
op.create_index('ix_guardrail_events_org_created', 'guardrail_events',
['organization_id', 'created_at'])
op.create_index('ix_guardrail_events_rule_created', 'guardrail_events',
['rule', 'created_at'])
op.add_column('agents', sa.Column('topic_scope', sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column('agents', 'topic_scope')
op.drop_index('ix_guardrail_events_rule_created', table_name='guardrail_events')
op.drop_index('ix_guardrail_events_org_created', table_name='guardrail_events')
op.drop_table('guardrail_events')
@@ -0,0 +1,33 @@
"""add chat_widget_enabled to help_center_settings
Splits the public help center's two AI surfaces into independent toggles: the
inline AI quick-summary in search (ai_search_enabled) and the embedded chat
widget (chat_widget_enabled). Existing rows default to enabled to preserve the
prior behaviour, where a mapped agent's widget was shown alongside AI search.
Revision ID: add_hc_chat_widget_001
Revises: f7a1c2d3e4b5
Create Date: 2026-07-13
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'add_hc_chat_widget_001'
down_revision = 'f7a1c2d3e4b5'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
'help_center_settings',
sa.Column('chat_widget_enabled', sa.Boolean(), nullable=False, server_default=sa.true()),
)
# Drop the server default now that existing rows are backfilled — the model
# supplies the default on insert, matching ai_search_enabled.
op.alter_column('help_center_settings', 'chat_widget_enabled', server_default=None)
def downgrade() -> None:
op.drop_column('help_center_settings', 'chat_widget_enabled')
@@ -0,0 +1,30 @@
"""add cta_enabled to help_center_settings
Lets the primary button (CTA) in the public help-center header be toggled on/off
independently of whether its text/URL are filled in. Existing rows default to
enabled to preserve the prior behaviour (CTA shown when text and URL are set).
Revision ID: add_hc_cta_enabled_001
Revises: add_hc_chat_widget_001
Create Date: 2026-07-13
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'add_hc_cta_enabled_001'
down_revision = 'add_hc_chat_widget_001'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
'help_center_settings',
sa.Column('cta_enabled', sa.Boolean(), nullable=False, server_default=sa.true()),
)
op.alter_column('help_center_settings', 'cta_enabled', server_default=None)
def downgrade() -> None:
op.drop_column('help_center_settings', 'cta_enabled')
@@ -0,0 +1,42 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Per-article "Was this helpful?" counters on faqs: helpful_yes / helpful_no.
Votes are aggregated as counts (de-duplicated per IP/day at the API); no
per-vote rows are stored.
Revision ID: add_hc_faq_feedback_001
Revises: add_hc_job_meter_001
"""
import sqlalchemy as sa
from alembic import op
revision = "add_hc_faq_feedback_001"
down_revision = "add_hc_job_meter_001"
branch_labels = None
depends_on = None
TABLE = "faqs"
def upgrade() -> None:
op.add_column(TABLE, sa.Column("helpful_yes", sa.Integer(), nullable=False, server_default="0"))
op.add_column(TABLE, sa.Column("helpful_no", sa.Integer(), nullable=False, server_default="0"))
def downgrade() -> None:
op.drop_column(TABLE, "helpful_no")
op.drop_column(TABLE, "helpful_yes")
@@ -0,0 +1,61 @@
"""add slug to faqs for public article-page URLs
FAQs become full help-center articles served at /a/{slug}. Adds a nullable
per-org-unique slug column, then backfills existing rows from their question
(deduping collisions with -2, -3, … within each org). New/edited FAQs get their
slug assigned in the API; this backfill covers everything created before.
Revision ID: add_hc_faq_slug_001
Revises: add_hc_cta_enabled_001
Create Date: 2026-07-13
"""
import re
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = 'add_hc_faq_slug_001'
down_revision = 'add_hc_cta_enabled_001'
branch_labels = None
depends_on = None
SLUG_MAX_LENGTH = 80
_SLUG_CLEAN_RE = re.compile(r"[^a-z0-9]+")
def _slugify(text: str) -> str:
base = _SLUG_CLEAN_RE.sub("-", (text or "").casefold()).strip("-")
return base[:SLUG_MAX_LENGTH].strip("-") or "article"
def _backfill(conn) -> None:
faqs = conn.execute(
sa.text("SELECT id, organization_id, question FROM faqs ORDER BY created_at ASC, id ASC")
).fetchall()
used: dict = {} # organization_id -> set(slug)
for faq_id, org_id, question in faqs:
seen = used.setdefault(org_id, set())
base = _slugify(question)
candidate = base
suffix = 2
while candidate in seen:
tail = f"-{suffix}"
candidate = f"{base[:SLUG_MAX_LENGTH - len(tail)]}{tail}"
suffix += 1
seen.add(candidate)
conn.execute(
sa.text("UPDATE faqs SET slug = :slug WHERE id = :id"),
{"slug": candidate, "id": faq_id},
)
def upgrade() -> None:
op.add_column('faqs', sa.Column('slug', sa.String(length=80), nullable=True))
_backfill(op.get_bind())
op.create_index('ix_faqs_org_slug', 'faqs', ['organization_id', 'slug'], unique=True)
def downgrade() -> None:
op.drop_index('ix_faqs_org_slug', table_name='faqs')
op.drop_column('faqs', 'slug')
@@ -0,0 +1,51 @@
"""preserve original article URLs when migrating a help center
An org importing an existing help center can now keep every article at the URL
it already ranks for: faqs.url_path is the root-relative path the article is
SERVED at, and /a/{slug} 301s to it. faqs.source_url records where an imported
article came from (provenance only, never routed on). The job flag carries the
import-time opt-in through to the worker.
No backfill on purpose — NULL url_path is exactly today's behaviour (/a/{slug}),
so nothing already published moves.
Revision ID: add_hc_faq_url_path_001
Revises: repair_agent_role_seed_001
Create Date: 2026-08-06
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = 'add_hc_faq_url_path_001'
down_revision = 'repair_agent_role_seed_001'
branch_labels = None
depends_on = None
URL_PATH_MAX_LENGTH = 400
SOURCE_URL_MAX_LENGTH = 2048
def upgrade() -> None:
op.add_column('faqs', sa.Column('url_path', sa.String(length=URL_PATH_MAX_LENGTH), nullable=True))
op.add_column('faqs', sa.Column('source_url', sa.String(length=SOURCE_URL_MAX_LENGTH), nullable=True))
# Serves the preserved-path lookup and enforces per-org uniqueness. Every
# existing row is NULL here, and Postgres treats NULLs as distinct, so the
# unique index applies cleanly without a backfill.
op.create_index('ix_faqs_org_url_path', 'faqs', ['organization_id', 'url_path'], unique=True)
op.add_column(
'faq_generation_jobs',
sa.Column(
'preserve_source_urls',
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
)
def downgrade() -> None:
op.drop_column('faq_generation_jobs', 'preserve_source_urls')
op.drop_index('ix_faqs_org_url_path', table_name='faqs')
op.drop_column('faqs', 'source_url')
op.drop_column('faqs', 'url_path')
@@ -0,0 +1,24 @@
"""Help center favicon URL
Revision ID: add_hc_favicon_001
Revises: merge_tkt_csat_001
Create Date: 2026-07-25
Adds a nullable favicon_url to help center settings, mirroring logo_url, so orgs
can set the browser-tab icon for their public help center from Customization.
"""
from alembic import op
import sqlalchemy as sa
revision = "add_hc_favicon_001"
down_revision = "merge_tkt_csat_001"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("help_center_settings", sa.Column("favicon_url", sa.String(), nullable=True))
def downgrade() -> None:
op.drop_column("help_center_settings", "favicon_url")
@@ -0,0 +1,48 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
FAQ generation-job metering + targeting columns:
- llm_calls / metered: per-job LLM call counter for hosted-model message
metering (metered stamped at enqueue from the org's AI config).
- knowledge_ids: optional explicit source narrowing for GENERATE_ALL jobs.
- source_file_name: original filename for PDF imports.
Revision ID: add_hc_job_meter_001
Revises: add_hc_faq_slug_001
"""
import sqlalchemy as sa
from alembic import op
revision = "add_hc_job_meter_001"
down_revision = "add_hc_faq_slug_001"
branch_labels = None
depends_on = None
TABLE = "faq_generation_jobs"
def upgrade() -> None:
op.add_column(TABLE, sa.Column("llm_calls", sa.Integer(), nullable=False, server_default="0"))
op.add_column(TABLE, sa.Column("metered", sa.Boolean(), nullable=False, server_default=sa.text("true")))
op.add_column(TABLE, sa.Column("knowledge_ids", sa.JSON(), nullable=True))
op.add_column(TABLE, sa.Column("source_file_name", sa.String(length=255), nullable=True))
def downgrade() -> None:
op.drop_column(TABLE, "source_file_name")
op.drop_column(TABLE, "knowledge_ids")
op.drop_column(TABLE, "metered")
op.drop_column(TABLE, "llm_calls")
@@ -0,0 +1,124 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""add investigation glass-box tables (hypotheses, events, RCA documents)
Revision ID: add_invest_glassbox_001
Revises: merge_tkt_phone_001
Create Date: 2026-07-17
Phase 3 of AI-first ticketing: hypothesis-driven investigation with evidence
captured as first-class rows (investigation_events) and structured, versioned
RCA documents.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'add_invest_glassbox_001'
down_revision = 'merge_tkt_phone_001'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
'investigation_hypotheses',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('run_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('ticket_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('idx', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=300), nullable=False),
sa.Column('rationale', sa.Text(), nullable=True),
sa.Column('status', sa.String(), nullable=False),
sa.Column('confidence', sa.Float(), nullable=True),
sa.Column('conclusion', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['run_id'], ['investigation_runs.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['ticket_id'], ['tickets.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_investigation_hypotheses_run_id'), 'investigation_hypotheses', ['run_id'])
op.create_index(op.f('ix_investigation_hypotheses_ticket_id'), 'investigation_hypotheses', ['ticket_id'])
op.create_table(
'investigation_events',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('run_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('ticket_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('hypothesis_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('seq', sa.Integer(), nullable=False),
sa.Column('event_type', sa.String(), nullable=False),
sa.Column('label', sa.String(length=300), nullable=True),
sa.Column('tool_name', sa.String(length=200), nullable=True),
sa.Column('connector_name', sa.String(length=200), nullable=True),
sa.Column('tool_input', sa.Text(), nullable=True),
sa.Column('tool_result', sa.Text(), nullable=True),
sa.Column('duration_ms', sa.Integer(), nullable=True),
sa.Column('error', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['run_id'], ['investigation_runs.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['ticket_id'], ['tickets.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['hypothesis_id'], ['investigation_hypotheses.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_investigation_events_run_id'), 'investigation_events', ['run_id'])
op.create_index(op.f('ix_investigation_events_ticket_id'), 'investigation_events', ['ticket_id'])
op.create_index('ix_investigation_events_run_seq', 'investigation_events', ['run_id', 'seq'])
op.create_table(
'rca_documents',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('ticket_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('run_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('version', sa.Integer(), nullable=False),
sa.Column('summary', sa.Text(), nullable=True),
sa.Column('impact', sa.Text(), nullable=True),
sa.Column('timeline', postgresql.JSON(astext_type=sa.Text()), nullable=True),
sa.Column('investigation_log', sa.Text(), nullable=True),
sa.Column('contributing_factors', postgresql.JSON(astext_type=sa.Text()), nullable=True),
sa.Column('conclusion', sa.Text(), nullable=True),
sa.Column('remediation', sa.Text(), nullable=True),
sa.Column('prevention', sa.Text(), nullable=True),
sa.Column('customer_summary', sa.Text(), nullable=True),
sa.Column('confidence', sa.Float(), nullable=True),
sa.Column('is_partial', sa.Boolean(), server_default=sa.text('false'), nullable=False),
sa.Column('generated_by', sa.String(), server_default='ai', nullable=False),
sa.Column('reviewed_by_user_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('reviewed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['ticket_id'], ['tickets.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['run_id'], ['investigation_runs.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['reviewed_by_user_id'], ['users.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('ticket_id', 'version', name='uq_rca_documents_ticket_version'),
)
op.create_index(op.f('ix_rca_documents_ticket_id'), 'rca_documents', ['ticket_id'])
def downgrade() -> None:
op.drop_index(op.f('ix_rca_documents_ticket_id'), table_name='rca_documents')
op.drop_table('rca_documents')
op.drop_index('ix_investigation_events_run_seq', table_name='investigation_events')
op.drop_index(op.f('ix_investigation_events_ticket_id'), table_name='investigation_events')
op.drop_index(op.f('ix_investigation_events_run_id'), table_name='investigation_events')
op.drop_table('investigation_events')
op.drop_index(op.f('ix_investigation_hypotheses_ticket_id'), table_name='investigation_hypotheses')
op.drop_index(op.f('ix_investigation_hypotheses_run_id'), table_name='investigation_hypotheses')
op.drop_table('investigation_hypotheses')
@@ -0,0 +1,41 @@
"""Add metered flag to investigation_runs
Revision ID: add_tkt_metered_001
Revises: add_tkt_row_scope_001
Create Date: 2026-07-21
Marks whether an investigation run's LLM usage counts against the org's message
budget (hosted model only). Existing rows default to false — nothing is
retroactively metered on upgrade.
"""
from alembic import op
import sqlalchemy as sa
revision = "add_tkt_metered_001"
down_revision = "add_tkt_row_scope_001"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"investigation_runs",
sa.Column(
"metered",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
)
# The enterprise message-limit check sums llm_calls of metered runs per
# billing window — an index on (org, metered, created_at) keeps that cheap.
op.create_index(
"ix_investigation_runs_metered_period",
"investigation_runs",
["organization_id", "metered", "created_at"],
)
def downgrade() -> None:
op.drop_index("ix_investigation_runs_metered_period", table_name="investigation_runs")
op.drop_column("investigation_runs", "metered")
@@ -0,0 +1,93 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""add investigation_runs (AI triage/investigation queue)
Revision ID: add_investigation_runs_001
Revises: add_ticketing_core_001
Create Date: 2026-07-16
Phase 2 of AI-first ticketing: the run table doubles as the worker queue
(ticket_investigator polls status='pending', mirroring faq_generation_jobs).
Budgets and token actuals live on the row for cost tracking.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'add_investigation_runs_001'
down_revision = 'add_ticketing_core_001'
branch_labels = None
depends_on = None
ACTIVE_RUN_STATUSES = ('pending', 'running')
def upgrade() -> None:
op.create_table(
'investigation_runs',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('ticket_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('organization_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('run_type', sa.String(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('trigger', sa.String(), nullable=False),
sa.Column('requested_by_user_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('context_note', sa.Text(), nullable=True),
sa.Column('max_tool_calls', sa.Integer(), server_default='25', nullable=False),
sa.Column('max_wall_seconds', sa.Integer(), server_default='600', nullable=False),
sa.Column('tool_calls_used', sa.Integer(), server_default='0', nullable=False),
sa.Column('llm_calls', sa.Integer(), server_default='0', nullable=False),
sa.Column('input_tokens', sa.BigInteger(), server_default='0', nullable=False),
sa.Column('output_tokens', sa.BigInteger(), server_default='0', nullable=False),
sa.Column('model_name', sa.String(), nullable=True),
sa.Column('error', sa.Text(), nullable=True),
sa.Column('started_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['ticket_id'], ['tickets.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['requested_by_user_id'], ['users.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_investigation_runs_ticket_id'), 'investigation_runs', ['ticket_id'])
op.create_index(op.f('ix_investigation_runs_organization_id'), 'investigation_runs', ['organization_id'])
# Keeps the worker's poll O(active rows) as terminal rows accumulate.
op.create_index(
'ix_investigation_runs_active',
'investigation_runs',
['status'],
postgresql_where=sa.text(
"status IN ({})".format(", ".join(f"'{s}'" for s in ACTIVE_RUN_STATUSES))
),
)
# Closes the enqueue check-then-insert race: at most one active run per
# ticket. Lives only in the migration (sqlite tests can't compile the
# partial WHERE).
op.execute(
"CREATE UNIQUE INDEX uq_investigation_runs_one_active ON investigation_runs "
"(ticket_id) WHERE status IN ('pending', 'running')"
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS uq_investigation_runs_one_active")
op.drop_index('ix_investigation_runs_active', table_name='investigation_runs')
op.drop_index(op.f('ix_investigation_runs_organization_id'), table_name='investigation_runs')
op.drop_index(op.f('ix_investigation_runs_ticket_id'), table_name='investigation_runs')
op.drop_table('investigation_runs')
@@ -0,0 +1,61 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""Add mcp_tools.usage_guidance
Operator-written notes on what an MCP source holds and how to query it.
Reaches the model as agno per-toolkit instructions so investigations stop
inventing index and field names (#318). Nullable with no server default, so
existing rows stay NULL and their prompts are unchanged.
Revision ID: add_mcp_usage_guidance_001
Revises: add_openai_compat_ai_model_001
Create Date: 2026-09-08 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'add_mcp_usage_guidance_001'
down_revision: Union[str, None] = 'add_openai_compat_ai_model_001'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _has_column() -> bool:
"""Development databases are shared between feature branches, so the column
can already exist by the time this runs. Adding it twice would abort the
upgrade."""
inspector = sa.inspect(op.get_bind())
return any(c['name'] == 'usage_guidance'
for c in inspector.get_columns('mcp_tools'))
def upgrade() -> None:
if _has_column():
return
op.add_column(
'mcp_tools',
sa.Column('usage_guidance', sa.Text(), nullable=True),
)
def downgrade() -> None:
if _has_column():
op.drop_column('mcp_tools', 'usage_guidance')
@@ -0,0 +1,57 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""add per-user chat notification preferences
Revision ID: add_notif_prefs_001
Revises: add_faq_seo_001
Create Date: 2026-07-22
Rows are created lazily on first read, so nothing is backfilled here — a user
without a row falls back to the column defaults (transfer/assignment on,
new-chat off), which keeps today's transfer notifications working unchanged.
The revision id is kept short — alembic_version.version_num is varchar(32).
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
# revision identifiers, used by Alembic.
revision = 'add_notif_prefs_001'
down_revision = 'add_faq_seo_001'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
'user_notification_settings',
sa.Column('user_id', UUID(as_uuid=True),
sa.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True),
sa.Column('notify_new_chat', sa.Boolean(), nullable=False,
server_default=sa.false()),
sa.Column('notify_chat_transfer', sa.Boolean(), nullable=False,
server_default=sa.true()),
sa.Column('notify_chat_assigned', sa.Boolean(), nullable=False,
server_default=sa.true()),
sa.Column('created_at', sa.DateTime(timezone=True),
server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
op.drop_table('user_notification_settings')
@@ -0,0 +1,40 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""Add OPENAI_COMPATIBLE AI model type
Revision ID: add_openai_compat_ai_model_001
Revises: fcm_tokens_per_device_001
Create Date: 2026-09-03 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'add_openai_compat_ai_model_001'
down_revision: Union[str, None] = 'fcm_tokens_per_device_001'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("ALTER TYPE aimodeltype ADD VALUE IF NOT EXISTS 'OPENAI_COMPATIBLE'")
def downgrade() -> None:
# PostgreSQL does not support removing enum values
pass
@@ -0,0 +1,44 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""Add require_token_auth to agents table
Revision ID: add_require_token_auth_001
Revises: add_allow_attachments_001
Create Date: 2025-12-01 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'add_require_token_auth_001'
down_revision: Union[str, Sequence[str]] = 'add_allow_attachments_001'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('agents',
sa.Column('require_token_auth', sa.Boolean(), nullable=False, server_default='false')
)
def downgrade() -> None:
op.drop_column('agents', 'require_token_auth')
@@ -0,0 +1,44 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""add investigation_runs.connector_status
Revision ID: add_run_connector_status_001
Revises: add_ai_disclaimer_001
Create Date: 2026-07-31
Records the MCP connector outcome of each investigation run ({"configured": N,
"loaded": M, "failed": [{"name", "error"}]}) so a run that quietly executed
with 0 of its configured tools (e.g. npx missing in the image, issue #271) is
visible on the run detail instead of only in worker logs.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSON
# revision identifiers, used by Alembic.
revision = 'add_run_connector_status_001'
down_revision = 'add_ai_disclaimer_001'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('investigation_runs', sa.Column('connector_status', JSON(), nullable=True))
def downgrade() -> None:
op.drop_column('investigation_runs', 'connector_status')
@@ -0,0 +1,67 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""add sentiment analysis columns
Revision ID: add_sentiment_analysis_001
Revises: add_widget_apps_001
Create Date: 2026-03-16 11:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'add_sentiment_analysis_001'
down_revision: Union[str, None] = 'add_widget_apps_001'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add sentiment columns to chat_history
op.add_column('chat_history', sa.Column('sentiment_label', sa.String(), nullable=True))
op.add_column('chat_history', sa.Column('sentiment_score', sa.Float(), nullable=True))
# Add sentiment columns to session_to_agents
op.add_column('session_to_agents', sa.Column('sentiment_label', sa.String(), nullable=True))
op.add_column('session_to_agents', sa.Column('sentiment_score', sa.Float(), nullable=True))
# Create indexes for efficient sentiment queries
op.create_index(
'ix_chat_history_sentiment_label',
'chat_history',
['sentiment_label'],
unique=False
)
op.create_index(
'ix_session_to_agents_sentiment_label',
'session_to_agents',
['sentiment_label'],
unique=False
)
def downgrade() -> None:
op.drop_index('ix_session_to_agents_sentiment_label', table_name='session_to_agents')
op.drop_index('ix_chat_history_sentiment_label', table_name='chat_history')
op.drop_column('session_to_agents', 'sentiment_score')
op.drop_column('session_to_agents', 'sentiment_label')
op.drop_column('chat_history', 'sentiment_score')
op.drop_column('chat_history', 'sentiment_label')
@@ -0,0 +1,165 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""Add Slack integration tables
Revision ID: add_slack_integration_001
Revises: add_allow_attachments_001
Create Date: 2025-11-25 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'add_slack_integration_001'
down_revision: Union[str, None] = 'add_allow_attachments_001'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create StorageMode enum type if it doesn't exist
connection = op.get_bind()
result = connection.execute(sa.text(
"SELECT 1 FROM pg_type WHERE typname = 'storagemode'"
))
if not result.fetchone():
op.execute("CREATE TYPE storagemode AS ENUM ('FULL_CONTENT', 'METADATA_ONLY', 'EMBEDDINGS_ONLY')")
# Create slack_tokens table
op.create_table('slack_tokens',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('organization_id', sa.UUID(), nullable=False),
sa.Column('access_token', sa.String(), nullable=False),
sa.Column('bot_user_id', sa.String(), nullable=False),
sa.Column('team_id', sa.String(), nullable=False),
sa.Column('team_name', sa.String(), nullable=False),
sa.Column('authed_user_id', sa.String(), nullable=True),
sa.Column('scope', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_slack_tokens_id'), 'slack_tokens', ['id'], unique=False)
op.create_index(op.f('ix_slack_tokens_organization_id'), 'slack_tokens', ['organization_id'], unique=False)
op.create_index(op.f('ix_slack_tokens_team_id'), 'slack_tokens', ['team_id'], unique=False)
# Create slack_workspace_configs table
op.create_table('slack_workspace_configs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('organization_id', sa.UUID(), nullable=False),
sa.Column('team_id', sa.String(), nullable=False),
sa.Column('allowed_channel_ids', sa.JSON(), nullable=True),
sa.Column('storage_mode', postgresql.ENUM('FULL_CONTENT', 'METADATA_ONLY', 'EMBEDDINGS_ONLY', name='storagemode', create_type=False), nullable=False),
sa.Column('default_agent_id', sa.UUID(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['default_agent_id'], ['agents.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_slack_workspace_configs_id'), 'slack_workspace_configs', ['id'], unique=False)
op.create_index(op.f('ix_slack_workspace_configs_organization_id'), 'slack_workspace_configs', ['organization_id'], unique=False)
op.create_index(op.f('ix_slack_workspace_configs_team_id'), 'slack_workspace_configs', ['team_id'], unique=False)
# Create agent_slack_configs table
op.create_table('agent_slack_configs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('organization_id', sa.UUID(), nullable=False),
sa.Column('team_id', sa.String(), nullable=False),
sa.Column('agent_id', sa.UUID(), nullable=False),
sa.Column('channel_id', sa.String(), nullable=False),
sa.Column('channel_name', sa.String(), nullable=False),
sa.Column('enabled', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('respond_to_mentions', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('respond_to_reactions', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('respond_to_commands', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('reaction_emoji', sa.String(), nullable=False, server_default='robot_face'),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['agent_id'], ['agents.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('organization_id', 'channel_id', name='uq_agent_slack_config_org_channel')
)
op.create_index(op.f('ix_agent_slack_configs_id'), 'agent_slack_configs', ['id'], unique=False)
op.create_index(op.f('ix_agent_slack_configs_organization_id'), 'agent_slack_configs', ['organization_id'], unique=False)
op.create_index(op.f('ix_agent_slack_configs_team_id'), 'agent_slack_configs', ['team_id'], unique=False)
op.create_index(op.f('ix_agent_slack_configs_agent_id'), 'agent_slack_configs', ['agent_id'], unique=False)
op.create_index(op.f('ix_agent_slack_configs_channel_id'), 'agent_slack_configs', ['channel_id'], unique=False)
# Create slack_conversations table
op.create_table('slack_conversations',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('team_id', sa.String(), nullable=False),
sa.Column('channel_id', sa.String(), nullable=False),
sa.Column('thread_ts', sa.String(), nullable=False),
sa.Column('session_id', sa.UUID(), nullable=False),
sa.Column('agent_id', sa.UUID(), nullable=True),
sa.Column('organization_id', sa.UUID(), nullable=False),
sa.Column('slack_user_id', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['session_id'], ['session_to_agents.session_id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['agent_id'], ['agents.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('team_id', 'channel_id', 'thread_ts', name='uq_slack_conversation_thread')
)
op.create_index(op.f('ix_slack_conversations_id'), 'slack_conversations', ['id'], unique=False)
op.create_index(op.f('ix_slack_conversations_team_id'), 'slack_conversations', ['team_id'], unique=False)
op.create_index(op.f('ix_slack_conversations_channel_id'), 'slack_conversations', ['channel_id'], unique=False)
op.create_index(op.f('ix_slack_conversations_thread_ts'), 'slack_conversations', ['thread_ts'], unique=False)
op.create_index(op.f('ix_slack_conversations_session_id'), 'slack_conversations', ['session_id'], unique=False)
def downgrade() -> None:
# Drop slack_conversations table
op.drop_index(op.f('ix_slack_conversations_session_id'), table_name='slack_conversations')
op.drop_index(op.f('ix_slack_conversations_thread_ts'), table_name='slack_conversations')
op.drop_index(op.f('ix_slack_conversations_channel_id'), table_name='slack_conversations')
op.drop_index(op.f('ix_slack_conversations_team_id'), table_name='slack_conversations')
op.drop_index(op.f('ix_slack_conversations_id'), table_name='slack_conversations')
op.drop_table('slack_conversations')
# Drop agent_slack_configs table
op.drop_index(op.f('ix_agent_slack_configs_channel_id'), table_name='agent_slack_configs')
op.drop_index(op.f('ix_agent_slack_configs_agent_id'), table_name='agent_slack_configs')
op.drop_index(op.f('ix_agent_slack_configs_team_id'), table_name='agent_slack_configs')
op.drop_index(op.f('ix_agent_slack_configs_organization_id'), table_name='agent_slack_configs')
op.drop_index(op.f('ix_agent_slack_configs_id'), table_name='agent_slack_configs')
op.drop_table('agent_slack_configs')
# Drop slack_workspace_configs table
op.drop_index(op.f('ix_slack_workspace_configs_team_id'), table_name='slack_workspace_configs')
op.drop_index(op.f('ix_slack_workspace_configs_organization_id'), table_name='slack_workspace_configs')
op.drop_index(op.f('ix_slack_workspace_configs_id'), table_name='slack_workspace_configs')
op.drop_table('slack_workspace_configs')
# Drop slack_tokens table
op.drop_index(op.f('ix_slack_tokens_team_id'), table_name='slack_tokens')
op.drop_index(op.f('ix_slack_tokens_organization_id'), table_name='slack_tokens')
op.drop_index(op.f('ix_slack_tokens_id'), table_name='slack_tokens')
op.drop_table('slack_tokens')
# Drop StorageMode enum type
sa.Enum('FULL_CONTENT', 'METADATA_ONLY', 'EMBEDDINGS_ONLY', name='storagemode').drop(op.get_bind(), checkfirst=True)
@@ -0,0 +1,43 @@
"""CSAT score linkage on tickets
Revision ID: add_tkt_csat_001
Revises: add_tkt_row_scope_001
Create Date: 2026-07-22
The CSAT ask already goes out on close; nothing carried the answer back. These
columns close the loop: when the customer rates the conversation linked to a
ticket, the score lands on the ticket itself, so AI-resolved vs human-resolved
CSAT is a plain query instead of a join through ratings/sessions.
"""
from alembic import op
import sqlalchemy as sa
revision = "add_tkt_csat_001"
down_revision = "add_tkt_row_scope_001"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("tickets", sa.Column("csat_requested_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("tickets", sa.Column("csat_score", sa.Integer(), nullable=True))
op.add_column("tickets", sa.Column("csat_rating_id", sa.UUID(), nullable=True))
op.add_column("tickets", sa.Column("csat_responded_at", sa.DateTime(timezone=True), nullable=True))
op.create_foreign_key(
"fk_tickets_csat_rating_id",
"tickets",
"ratings",
["csat_rating_id"],
["id"],
ondelete="SET NULL",
)
op.create_index("ix_tickets_org_csat", "tickets", ["organization_id", "csat_responded_at"])
def downgrade() -> None:
op.drop_index("ix_tickets_org_csat", table_name="tickets")
op.drop_constraint("fk_tickets_csat_rating_id", "tickets", type_="foreignkey")
op.drop_column("tickets", "csat_responded_at")
op.drop_column("tickets", "csat_rating_id")
op.drop_column("tickets", "csat_score")
op.drop_column("tickets", "csat_requested_at")
@@ -0,0 +1,229 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""add native AI-first ticketing core tables
Revision ID: add_ticketing_core_001
Revises: add_hc_faq_feedback_001
Create Date: 2026-07-16
New feature: native AI-first ticketing (replaces the Jira-only flow). Tickets
are first-class aggregates decoupled from chat sessions; the legacy
session_to_agents.ticket_* columns remain as a denormalized mirror.
Tables:
- tickets: the aggregate root (per-org TKT-{n} numbers, triage fields,
pgvector embedding for dedup, SLA timestamps, external escalation ref)
- ticket_sequences: per-org display-number allocator (SELECT ... FOR UPDATE)
- ticket_sessions: ticket <-> chat-session link table
- ticket_activities: append-only comments + audit trail
- organization_ticket_settings: autonomy level, SLA targets, comms templates,
investigation budgets
Status/priority/source columns are plain strings (mirroring
faq_generation_jobs) so new values never need an ALTER TYPE migration.
Also seeds view_tickets/manage_tickets permissions and attaches them to
existing roles that already hold manage_all_chats (new orgs get them from
Permission.default_permissions()).
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'add_ticketing_core_001'
down_revision = 'add_hc_faq_feedback_001'
branch_labels = None
depends_on = None
TICKET_EMBEDDING_DIM = 384
def upgrade() -> None:
# pgvector for the dedup embedding (idempotent; also created by
# scripts/init-vector.sql on fresh databases).
op.execute("CREATE EXTENSION IF NOT EXISTS vector")
op.create_table(
'tickets',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('ticket_number', sa.Integer(), nullable=False),
sa.Column('organization_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('customer_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('title', sa.String(length=500), nullable=False),
sa.Column('original_title', sa.String(length=500), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('status', sa.String(), nullable=False),
sa.Column('priority', sa.String(), nullable=False),
sa.Column('severity', sa.Integer(), nullable=True),
sa.Column('source', sa.String(), nullable=False),
sa.Column('intent', sa.String(), nullable=True),
sa.Column('triage_confidence', sa.Float(), nullable=True),
sa.Column('ai_summary', sa.Text(), nullable=True),
sa.Column('tags', sa.JSON(), nullable=True),
sa.Column('assignee_user_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('group_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('agent_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('duplicate_of_ticket_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('resolution_outcome', sa.String(), nullable=True),
sa.Column('resolution_summary', sa.Text(), nullable=True),
sa.Column('customer_resolution_message', sa.Text(), nullable=True),
sa.Column('resolved_by_actor', sa.String(), nullable=True),
sa.Column('first_response_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('resolved_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('closed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('confirmation_requested_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('reopened_count', sa.Integer(), server_default='0', nullable=False),
sa.Column('external_ref_type', sa.String(), nullable=True),
sa.Column('external_ref_id', sa.String(), nullable=True),
sa.Column('external_ref_url', sa.String(), nullable=True),
sa.Column('created_by_user_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['customer_id'], ['customers.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['assignee_user_id'], ['users.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['agent_id'], ['agents.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['duplicate_of_ticket_id'], ['tickets.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['created_by_user_id'], ['users.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('organization_id', 'ticket_number', name='uq_tickets_org_number'),
)
# The vector column via raw SQL so the sqlalchemy migration stays free of
# the pgvector python type.
op.execute(f"ALTER TABLE tickets ADD COLUMN embedding vector({TICKET_EMBEDDING_DIM})")
op.create_index(op.f('ix_tickets_organization_id'), 'tickets', ['organization_id'])
op.create_index(op.f('ix_tickets_status'), 'tickets', ['status'])
op.create_index('ix_tickets_org_status', 'tickets', ['organization_id', 'status'])
op.create_index('ix_tickets_org_assignee', 'tickets', ['organization_id', 'assignee_user_id'])
op.create_index('ix_tickets_org_created', 'tickets', ['organization_id', 'created_at'])
# Approximate-NN index for dedup/similar-ticket recall (cosine).
op.execute(
"CREATE INDEX ix_tickets_embedding_hnsw ON tickets "
"USING hnsw (embedding vector_cosine_ops)"
)
op.create_table(
'ticket_sequences',
sa.Column('organization_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('next_number', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('organization_id'),
)
op.create_table(
'ticket_sessions',
sa.Column('ticket_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('session_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('linked_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['ticket_id'], ['tickets.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['session_id'], ['session_to_agents.session_id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('ticket_id', 'session_id'),
)
op.create_index(op.f('ix_ticket_sessions_session_id'), 'ticket_sessions', ['session_id'])
op.create_table(
'ticket_activities',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('ticket_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('activity_type', sa.String(), nullable=False),
sa.Column('actor_type', sa.String(), nullable=False),
sa.Column('actor_user_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('body', sa.Text(), nullable=True),
sa.Column('is_internal', sa.Boolean(), server_default=sa.true(), nullable=False),
sa.Column('activity_metadata', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['ticket_id'], ['tickets.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['actor_user_id'], ['users.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_ticket_activities_ticket_id'), 'ticket_activities', ['ticket_id'])
op.create_index('ix_ticket_activities_ticket_created', 'ticket_activities', ['ticket_id', 'created_at'])
op.create_table(
'organization_ticket_settings',
sa.Column('organization_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('autonomy_level', sa.Integer(), server_default='1', nullable=False),
sa.Column('auto_investigate_on_create', sa.Boolean(), server_default=sa.true(), nullable=False),
sa.Column('min_confidence_to_auto_resolve', sa.Float(), server_default='0.85', nullable=False),
sa.Column('confirmation_timeout_hours', sa.Integer(), server_default='72', nullable=False),
sa.Column('csat_enabled', sa.Boolean(), server_default=sa.true(), nullable=False),
sa.Column('sla_targets', sa.JSON(), nullable=True),
sa.Column('created_template', sa.Text(), nullable=True),
sa.Column('resolved_template', sa.Text(), nullable=True),
sa.Column('jira_escalation_enabled', sa.Boolean(), server_default=sa.false(), nullable=False),
sa.Column('jira_escalation_priority', sa.String(), nullable=True),
sa.Column('investigation_mcp_tool_ids', sa.JSON(), nullable=True),
sa.Column('alert_webhook_enabled', sa.Boolean(), server_default=sa.false(), nullable=False),
sa.Column('max_tool_calls_per_run', sa.Integer(), server_default='25', nullable=False),
sa.Column('max_runs_per_ticket', sa.Integer(), server_default='3', nullable=False),
sa.Column('monthly_investigation_cap', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('organization_id'),
)
# Seed the new permissions and attach them to existing roles that already
# manage chats (new orgs seed via Permission.default_permissions()).
op.execute("""
INSERT INTO permissions (name, description)
SELECT 'view_tickets', 'Can view tickets'
WHERE NOT EXISTS (SELECT 1 FROM permissions WHERE name = 'view_tickets')
""")
op.execute("""
INSERT INTO permissions (name, description)
SELECT 'manage_tickets', 'Can manage tickets'
WHERE NOT EXISTS (SELECT 1 FROM permissions WHERE name = 'manage_tickets')
""")
op.execute("""
INSERT INTO role_permissions (role_id, permission_id)
SELECT rp.role_id, p_new.id
FROM role_permissions rp
JOIN permissions p_old ON p_old.id = rp.permission_id AND p_old.name = 'manage_all_chats'
CROSS JOIN permissions p_new
WHERE p_new.name IN ('view_tickets', 'manage_tickets')
AND NOT EXISTS (
SELECT 1 FROM role_permissions rp2
WHERE rp2.role_id = rp.role_id AND rp2.permission_id = p_new.id
)
""")
def downgrade() -> None:
op.execute("""
DELETE FROM role_permissions
WHERE permission_id IN (
SELECT id FROM permissions WHERE name IN ('view_tickets', 'manage_tickets')
)
""")
op.execute("DELETE FROM permissions WHERE name IN ('view_tickets', 'manage_tickets')")
op.drop_table('organization_ticket_settings')
op.drop_index('ix_ticket_activities_ticket_created', table_name='ticket_activities')
op.drop_index(op.f('ix_ticket_activities_ticket_id'), table_name='ticket_activities')
op.drop_table('ticket_activities')
op.drop_index(op.f('ix_ticket_sessions_session_id'), table_name='ticket_sessions')
op.drop_table('ticket_sessions')
op.drop_table('ticket_sequences')
op.execute("DROP INDEX IF EXISTS ix_tickets_embedding_hnsw")
op.drop_index('ix_tickets_org_created', table_name='tickets')
op.drop_index('ix_tickets_org_assignee', table_name='tickets')
op.drop_index('ix_tickets_org_status', table_name='tickets')
op.drop_index(op.f('ix_tickets_status'), table_name='tickets')
op.drop_index(op.f('ix_tickets_organization_id'), table_name='tickets')
op.drop_table('tickets')
@@ -0,0 +1,54 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""add SSH tunnel fields to ticket_db_connectors
Revision ID: add_tkt_db_ssh_001
Revises: add_tkt_phase4_001
Create Date: 2026-07-17
Production databases are usually reached through a bastion / jump host rather
than directly — this adds optional SSH-tunnel config to the guardrailed DB
connector (key/password encrypted at rest).
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'add_tkt_db_ssh_001'
down_revision = 'add_tkt_phase4_001'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('ticket_db_connectors', sa.Column('ssh_enabled', sa.Boolean(), server_default=sa.text('false'), nullable=False))
op.add_column('ticket_db_connectors', sa.Column('ssh_host', sa.String(length=500), nullable=True))
op.add_column('ticket_db_connectors', sa.Column('ssh_port', sa.Integer(), server_default='22', nullable=False))
op.add_column('ticket_db_connectors', sa.Column('ssh_username', sa.String(length=200), nullable=True))
op.add_column('ticket_db_connectors', sa.Column('encrypted_ssh_password', sa.Text(), nullable=True))
op.add_column('ticket_db_connectors', sa.Column('encrypted_ssh_private_key', sa.Text(), nullable=True))
op.add_column('ticket_db_connectors', sa.Column('encrypted_ssh_key_passphrase', sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column('ticket_db_connectors', 'encrypted_ssh_key_passphrase')
op.drop_column('ticket_db_connectors', 'encrypted_ssh_private_key')
op.drop_column('ticket_db_connectors', 'encrypted_ssh_password')
op.drop_column('ticket_db_connectors', 'ssh_username')
op.drop_column('ticket_db_connectors', 'ssh_port')
op.drop_column('ticket_db_connectors', 'ssh_host')
op.drop_column('ticket_db_connectors', 'ssh_enabled')
@@ -0,0 +1,165 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""add ticketing Phase 4: guardrailed DB connectors, proposals, webhook secret
Revision ID: add_tkt_phase4_001
Revises: add_invest_glassbox_001
Create Date: 2026-07-17
- ticket_db_connectors: read-only, allowlisted, masked DB access for the
investigation agent (creds encrypted at rest)
- db_connector_audit_logs: every attempted query (raw + canonical SQL,
outcome) — result rows are never stored
- ticket_proposals: autonomy L2 propose/approve records
- organization_ticket_settings.alert_webhook_secret for the alert intake
- seeds approve_ticket_actions (roles with manage_tickets) and
manage_ticket_connectors (roles with manage_organization)
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'add_tkt_phase4_001'
down_revision = 'add_invest_glassbox_001'
branch_labels = None
depends_on = None
NEW_PERMISSIONS = {
'approve_ticket_actions': ('Can approve AI-proposed ticket resolutions', 'manage_tickets'),
'manage_ticket_connectors': ('Can manage ticket investigation connectors', 'manage_organization'),
}
def upgrade() -> None:
op.create_table(
'ticket_db_connectors',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('organization_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('name', sa.String(length=200), nullable=False),
sa.Column('engine', sa.String(), nullable=False),
sa.Column('host', sa.String(length=500), nullable=False),
sa.Column('port', sa.Integer(), nullable=False),
sa.Column('database', sa.String(length=200), nullable=False),
sa.Column('username', sa.String(length=200), nullable=False),
sa.Column('encrypted_password', sa.Text(), nullable=False),
sa.Column('enabled', sa.Boolean(), server_default=sa.text('true'), nullable=False),
sa.Column('allowed_tables', postgresql.JSON(astext_type=sa.Text()), nullable=True),
sa.Column('masked_columns', postgresql.JSON(astext_type=sa.Text()), nullable=True),
sa.Column('max_rows', sa.Integer(), server_default='100', nullable=False),
sa.Column('statement_timeout_ms', sa.Integer(), server_default='5000', nullable=False),
sa.Column('last_test_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('last_test_ok', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_ticket_db_connectors_organization_id'), 'ticket_db_connectors', ['organization_id'])
op.create_table(
'db_connector_audit_logs',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('connector_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('organization_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('ticket_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('run_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('raw_sql', sa.Text(), nullable=False),
sa.Column('validated_sql', sa.Text(), nullable=True),
sa.Column('outcome', sa.String(), nullable=False),
sa.Column('block_reason', sa.Text(), nullable=True),
sa.Column('row_count', sa.Integer(), nullable=True),
sa.Column('duration_ms', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['connector_id'], ['ticket_db_connectors.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['ticket_id'], ['tickets.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['run_id'], ['investigation_runs.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_db_connector_audit_logs_connector_id'), 'db_connector_audit_logs', ['connector_id'])
op.create_index(op.f('ix_db_connector_audit_logs_organization_id'), 'db_connector_audit_logs', ['organization_id'])
op.create_index('ix_db_connector_audit_logs_created', 'db_connector_audit_logs', ['organization_id', 'created_at'])
op.create_table(
'ticket_proposals',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('ticket_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('organization_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('run_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('summary', sa.Text(), nullable=False),
sa.Column('customer_message', sa.Text(), nullable=True),
sa.Column('confidence', sa.Float(), nullable=True),
sa.Column('status', sa.String(), nullable=False),
sa.Column('decided_by_user_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('decided_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('reject_reason', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['ticket_id'], ['tickets.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['run_id'], ['investigation_runs.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['decided_by_user_id'], ['users.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_ticket_proposals_ticket_id'), 'ticket_proposals', ['ticket_id'])
op.create_index(op.f('ix_ticket_proposals_organization_id'), 'ticket_proposals', ['organization_id'])
op.add_column(
'organization_ticket_settings',
sa.Column('alert_webhook_secret', sa.String(length=64), nullable=True),
)
# Seed the new permissions onto roles holding the anchor permission
# (new orgs seed via Permission.default_permissions()).
for name, (description, anchor) in NEW_PERMISSIONS.items():
op.execute(f"""
INSERT INTO permissions (name, description)
SELECT '{name}', '{description}'
WHERE NOT EXISTS (SELECT 1 FROM permissions WHERE name = '{name}')
""")
op.execute(f"""
INSERT INTO role_permissions (role_id, permission_id)
SELECT rp.role_id, p_new.id
FROM role_permissions rp
JOIN permissions p_old ON p_old.id = rp.permission_id AND p_old.name = '{anchor}'
CROSS JOIN permissions p_new
WHERE p_new.name = '{name}'
AND NOT EXISTS (
SELECT 1 FROM role_permissions rp2
WHERE rp2.role_id = rp.role_id AND rp2.permission_id = p_new.id
)
""")
def downgrade() -> None:
names = "', '".join(NEW_PERMISSIONS)
op.execute(f"""
DELETE FROM role_permissions
WHERE permission_id IN (SELECT id FROM permissions WHERE name IN ('{names}'))
""")
op.execute(f"DELETE FROM permissions WHERE name IN ('{names}')")
op.drop_column('organization_ticket_settings', 'alert_webhook_secret')
op.drop_index(op.f('ix_ticket_proposals_organization_id'), table_name='ticket_proposals')
op.drop_index(op.f('ix_ticket_proposals_ticket_id'), table_name='ticket_proposals')
op.drop_table('ticket_proposals')
op.drop_index('ix_db_connector_audit_logs_created', table_name='db_connector_audit_logs')
op.drop_index(op.f('ix_db_connector_audit_logs_organization_id'), table_name='db_connector_audit_logs')
op.drop_index(op.f('ix_db_connector_audit_logs_connector_id'), table_name='db_connector_audit_logs')
op.drop_table('db_connector_audit_logs')
op.drop_index(op.f('ix_ticket_db_connectors_organization_id'), table_name='ticket_db_connectors')
op.drop_table('ticket_db_connectors')
@@ -0,0 +1,67 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""Add widget_apps table
Revision ID: add_widget_apps_001
Revises: 2381319791e8
Create Date: 2026-01-13 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
# revision identifiers, used by Alembic.
revision: str = 'add_widget_apps_001'
down_revision: Union[str, Sequence[str]] = '2381319791e8'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create widget_apps table
op.create_table(
'widget_apps',
sa.Column('id', UUID(as_uuid=True), primary_key=True, nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('organization_id', UUID(as_uuid=True), nullable=False),
sa.Column('api_key_hash', sa.String(), nullable=False),
sa.Column('created_by', UUID(as_uuid=True), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id']),
sa.ForeignKeyConstraint(['created_by'], ['users.id']),
)
# Create indexes
op.create_index('ix_widget_apps_id', 'widget_apps', ['id'])
op.create_index('ix_widget_apps_organization_id', 'widget_apps', ['organization_id'])
op.create_index('ix_widget_apps_is_active', 'widget_apps', ['is_active'])
def downgrade() -> None:
# Drop indexes
op.drop_index('ix_widget_apps_is_active', table_name='widget_apps')
op.drop_index('ix_widget_apps_organization_id', table_name='widget_apps')
op.drop_index('ix_widget_apps_id', table_name='widget_apps')
# Drop table
op.drop_table('widget_apps')
@@ -0,0 +1,30 @@
"""Let an agent offer reply buttons on messaging channels
Off by default: an agent shows the AI's choices as tappable buttons on
WhatsApp, Messenger, Instagram and Telegram only once someone turns it on.
Revision ID: agent_quick_replies_001
Revises: chat_channel_status_001
Create Date: 2026-10-11
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
# Keep revision ids under 32 characters: alembic_version.version_num is
# varchar(32).
revision: str = 'agent_quick_replies_001'
down_revision: Union[str, None] = 'chat_channel_status_001'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('agents', sa.Column('channel_quick_replies', sa.Boolean(), nullable=False,
server_default=sa.false()), if_not_exists=True)
def downgrade() -> None:
op.drop_column('agents', 'channel_quick_replies', if_exists=True)
@@ -0,0 +1,39 @@
"""auth_sessions table, so sign-out and password changes revoke JWTs
Revision ID: auth_sessions_001
Revises: agent_quick_replies_001
Create Date: 2026-10-12
Tokens issued before this revision carry no session id and stay valid until
they expire, so the deploy signs nobody out.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'auth_sessions_001'
down_revision: Union[str, None] = 'agent_quick_replies_001'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'auth_sessions',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True),
server_default=sa.text('now()'), nullable=True),
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
)
op.create_index('ix_auth_sessions_user_id', 'auth_sessions', ['user_id'])
def downgrade() -> None:
op.drop_index('ix_auth_sessions_user_id', table_name='auth_sessions')
op.drop_table('auth_sessions')
@@ -0,0 +1,50 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""add meta_data column to customers
Adds:
- `meta_data` (JSON): arbitrary integrator-supplied fields (e.g. student_name,
center_name) passed via POST /generate-token's `custom_data`, surfaced to agents
in the chat inbox.
Revision ID: b2c3d4e5f6a7
Revises: f4a5b6c7d8e9
Create Date: 2026-07-06 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'b2c3d4e5f6a7'
down_revision: Union[str, None] = 'f4a5b6c7d8e9'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'customers',
sa.Column('meta_data', sa.JSON(), nullable=True)
)
def downgrade() -> None:
op.drop_column('customers', 'meta_data')
@@ -0,0 +1,310 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'b365b8447598'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('organizations',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('domain', sa.String(length=100), nullable=False),
sa.Column('timezone', sa.String(length=50), nullable=False),
sa.Column('business_hours', sa.JSON(), nullable=True),
sa.Column('settings', sa.JSON(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('domain')
)
op.create_index(op.f('ix_organizations_id'), 'organizations', ['id'], unique=False)
op.create_table('permissions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.String(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_index(op.f('ix_permissions_id'), 'permissions', ['id'], unique=False)
op.create_table('agents',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('display_name', sa.String(length=100), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('agent_type', sa.Enum('CUSTOMER_SUPPORT', 'SALES', 'TECH_SUPPORT', 'GENERAL', 'CUSTOM', name='agenttype'), nullable=False),
sa.Column('instructions', sa.Text(), nullable=False),
sa.Column('tools', sa.Text(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('organization_id', sa.UUID(), nullable=True),
sa.Column('is_default', sa.Boolean(), nullable=True),
sa.Column('transfer_to_human', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('ai_configs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('organization_id', sa.UUID(), nullable=False),
sa.Column('model_type', sa.Enum('OPENAI', 'ANTHROPIC', 'DEEPSEEK', 'GOOGLE', 'GOOGLEVERTEX', 'GROQ', 'MISTRAL', 'HUGGINGFACE', 'OLLAMA', 'XAI', name='aimodeltype'), nullable=False),
sa.Column('model_name', sa.String(), nullable=False),
sa.Column('encrypted_api_key', sa.String(), nullable=False),
sa.Column('settings', sa.JSON(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_ai_configs_id'), 'ai_configs', ['id'], unique=False)
op.create_table('customers',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('email', sa.String(), nullable=False),
sa.Column('full_name', sa.String(), nullable=True),
sa.Column('organization_id', sa.UUID(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email', 'organization_id', name='uix_customer_email_org')
)
op.create_table('groups',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.String(), nullable=True),
sa.Column('organization_id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('knowledge',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('source', sa.String(), nullable=False),
sa.Column('source_type', sa.Enum('DATABASE', 'FILE', 'API', 'WEBSITE', 'CUSTOM', name='sourcetype'), nullable=False),
sa.Column('schema', sa.String(), nullable=True),
sa.Column('table_name', sa.String(), nullable=True),
sa.Column('organization_id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_knowledge_id'), 'knowledge', ['id'], unique=False)
op.create_table('roles',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.String(), nullable=True),
sa.Column('organization_id', sa.UUID(), nullable=True),
sa.Column('is_default', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_index(op.f('ix_roles_id'), 'roles', ['id'], unique=False)
op.create_table('agent_customizations',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('agent_id', sa.UUID(), nullable=True),
sa.Column('photo_url', sa.String(), nullable=True),
sa.Column('chat_background_color', sa.String(), nullable=True),
sa.Column('chat_bubble_color', sa.String(), nullable=True),
sa.Column('chat_text_color', sa.String(), nullable=True),
sa.Column('icon_url', sa.String(), nullable=True),
sa.Column('icon_color', sa.String(), nullable=True),
sa.Column('accent_color', sa.String(), nullable=True),
sa.Column('font_family', sa.String(), nullable=True),
sa.Column('custom_css', sa.Text(), nullable=True),
sa.Column('customization_metadata', sa.JSON(), nullable=True),
sa.ForeignKeyConstraint(['agent_id'], ['agents.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_agent_customizations_id'), 'agent_customizations', ['id'], unique=False)
op.create_table('agent_usergroup',
sa.Column('agent_id', sa.UUID(), nullable=True),
sa.Column('group_id', sa.UUID(), nullable=True),
sa.ForeignKeyConstraint(['agent_id'], ['agents.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ondelete='CASCADE')
)
op.create_table('knowledge_to_agents',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('knowledge_id', sa.Integer(), nullable=False),
sa.Column('agent_id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['agent_id'], ['agents.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['knowledge_id'], ['knowledge.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_knowledge_to_agents_id'), 'knowledge_to_agents', ['id'], unique=False)
op.create_table('role_permissions',
sa.Column('role_id', sa.Integer(), nullable=True),
sa.Column('permission_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['permission_id'], ['permissions.id'], ),
sa.ForeignKeyConstraint(['role_id'], ['roles.id'], )
)
op.create_table('users',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('email', sa.String(), nullable=True),
sa.Column('full_name', sa.String(), nullable=True),
sa.Column('hashed_password', sa.String(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('profile_pic', sa.String(), nullable=True),
sa.Column('is_online', sa.Boolean(), nullable=True),
sa.Column('last_seen', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('organization_id', sa.UUID(), nullable=True),
sa.Column('role_id', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('fcm_token_web', sa.String(), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_table('widgets',
sa.Column('id', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('organization_id', sa.UUID(), nullable=False),
sa.Column('agent_id', sa.UUID(), nullable=True),
sa.ForeignKeyConstraint(['agent_id'], ['agents.id'], ),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('knowledge_queue',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('organization_id', sa.UUID(), nullable=False),
sa.Column('agent_id', sa.UUID(), nullable=True),
sa.Column('user_id', sa.UUID(), nullable=False),
sa.Column('source_type', sa.String(), nullable=False),
sa.Column('source', sa.String(), nullable=False),
sa.Column('status', sa.String(), nullable=True),
sa.Column('error', sa.String(), nullable=True),
sa.Column('queue_metadata', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_knowledge_queue_id'), 'knowledge_queue', ['id'], unique=False)
op.create_table('notifications',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.UUID(), nullable=False),
sa.Column('type', sa.Enum('KNOWLEDGE_PROCESSED', 'KNOWLEDGE_FAILED', 'SYSTEM', 'CHAT', name='notificationtype'), nullable=False),
sa.Column('title', sa.String(), nullable=False),
sa.Column('message', sa.String(), nullable=False),
sa.Column('notification_metadata', sa.JSON(), nullable=True),
sa.Column('is_read', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_notifications_id'), 'notifications', ['id'], unique=False)
op.create_table('session_to_agents',
sa.Column('session_id', sa.UUID(), nullable=False),
sa.Column('user_id', sa.UUID(), nullable=True),
sa.Column('agent_id', sa.UUID(), nullable=True),
sa.Column('customer_id', sa.UUID(), nullable=False),
sa.Column('group_id', sa.UUID(), nullable=True),
sa.Column('organization_id', sa.UUID(), nullable=False),
sa.Column('status', sa.Enum('OPEN', 'CLOSED', 'TRANSFERRED', name='sessionstatus'), nullable=False),
sa.Column('assigned_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('closed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('transfer_reason', sa.String(), nullable=True),
sa.Column('transfer_description', sa.String(), nullable=True),
sa.ForeignKeyConstraint(['agent_id'], ['agents.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['customer_id'], ['customers.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('session_id')
)
op.create_table('user_groups',
sa.Column('user_id', sa.UUID(), nullable=False),
sa.Column('group_id', sa.UUID(), nullable=False),
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('user_id', 'group_id')
)
op.create_table('chat_history',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('organization_id', sa.UUID(), nullable=True),
sa.Column('user_id', sa.UUID(), nullable=True),
sa.Column('customer_id', sa.UUID(), nullable=True),
sa.Column('agent_id', sa.UUID(), nullable=True),
sa.Column('session_id', sa.UUID(), nullable=True),
sa.Column('message', sa.String(), nullable=False),
sa.Column('message_type', sa.String(), nullable=False),
sa.Column('attributes', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['agent_id'], ['agents.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['customer_id'], ['customers.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['session_id'], ['session_to_agents.session_id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_chat_history_id'), 'chat_history', ['id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_chat_history_id'), table_name='chat_history')
op.drop_table('chat_history')
op.drop_table('user_groups')
op.drop_table('session_to_agents')
op.drop_index(op.f('ix_notifications_id'), table_name='notifications')
op.drop_table('notifications')
op.drop_index(op.f('ix_knowledge_queue_id'), table_name='knowledge_queue')
op.drop_table('knowledge_queue')
op.drop_table('widgets')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
op.drop_table('role_permissions')
op.drop_index(op.f('ix_knowledge_to_agents_id'), table_name='knowledge_to_agents')
op.drop_table('knowledge_to_agents')
op.drop_table('agent_usergroup')
op.drop_index(op.f('ix_agent_customizations_id'), table_name='agent_customizations')
op.drop_table('agent_customizations')
op.drop_index(op.f('ix_roles_id'), table_name='roles')
op.drop_table('roles')
op.drop_index(op.f('ix_knowledge_id'), table_name='knowledge')
op.drop_table('knowledge')
op.drop_table('groups')
op.drop_table('customers')
op.drop_index(op.f('ix_ai_configs_id'), table_name='ai_configs')
op.drop_table('ai_configs')
op.drop_table('agents')
op.drop_index(op.f('ix_permissions_id'), table_name='permissions')
op.drop_table('permissions')
op.drop_index(op.f('ix_organizations_id'), table_name='organizations')
op.drop_table('organizations')
# ### end Alembic commands ###
@@ -0,0 +1,151 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""add view_unassigned_chats + view_people permissions
Revision ID: b7c4e91a2d38
Revises: add_customer_phone_001
Create Date: 2026-07-18
Agents could previously only see chats already assigned to them or their group,
so the AI queue was invisible and "Take over" was unreachable. Two new
permissions rather than widening view_assigned_chats, so that granting the
queue never also exposes another agent's conversations:
- view_unassigned_chats: the unclaimed queue (session_to_agents.user_id IS NULL)
- view_people: read-only people directory, previously gated on view_all_chats
Backfill targets roles by CAPABILITY, not by name, so every existing
organization is covered including ones whose agent role was renamed or
hand-built:
- any role that can view chats at all (assigned, all, or super_admin) gains
view_unassigned_chats
- the same roles plus manage_all_chats gain view_people
This deliberately widens existing roles: without it the feature would appear
broken for every deployment created before this release, and an admin who
wants an agent kept out can uncheck the permission in Roles. Re-runnable —
every insert is guarded by NOT EXISTS.
Also repairs the seeding bug where BOTH "Admin" and "Agent" were created with
is_default=true, which made RoleRepository.get_default_role() return whichever
row the database happened to yield first — an invited user could land on Admin.
Nothing in the API enforces a single default, so the repair is data-only.
The repair CLEARS Admin, which is what leaves Agent as the default. Setting
is_default=false on Agent instead would do the opposite of what is wanted: it
would leave Admin as the sole default and hand every newly invited user full
permissions.
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = 'b7c4e91a2d38'
down_revision = 'add_customer_phone_001'
branch_labels = None
depends_on = None
NEW_PERMISSIONS = (
('view_unassigned_chats', 'Can view unassigned AI chats'),
('view_people', 'Can view the people directory'),
)
# Which existing roles receive each new permission, expressed as the permissions
# a role must already hold. Capability, not role name: an org that renamed its
# agent role, or built its own, is still covered.
BACKFILL = (
('view_unassigned_chats', ('view_assigned_chats', 'view_all_chats', 'super_admin')),
('view_people', ('view_assigned_chats', 'view_all_chats', 'manage_all_chats', 'super_admin')),
)
def _sql_list(values) -> str:
return ", ".join(f"'{value}'" for value in values)
def upgrade() -> None:
for name, description in NEW_PERMISSIONS:
op.execute(
f"""
INSERT INTO permissions (name, description)
SELECT '{name}', '{description}'
WHERE NOT EXISTS (SELECT 1 FROM permissions WHERE name = '{name}')
"""
)
for new_permission, held_by in BACKFILL:
op.execute(
f"""
INSERT INTO role_permissions (role_id, permission_id)
SELECT DISTINCT r.id, target.id
FROM roles r
JOIN role_permissions rp ON rp.role_id = r.id
JOIN permissions held ON held.id = rp.permission_id
CROSS JOIN permissions target
WHERE target.name = '{new_permission}'
AND held.name IN ({_sql_list(held_by)})
AND NOT EXISTS (
SELECT 1 FROM role_permissions existing
WHERE existing.role_id = r.id
AND existing.permission_id = target.id
)
"""
)
# One default role per organization. Clearing Admin is what PROMOTES Agent
# to sole default — the opposite spelling (clearing Agent) would leave
# Admin default and make every invited user an admin.
#
# The COUNT(*) > 1 guard is load-bearing: it means an org whose only
# default is Admin keeps it, rather than being left with no default role
# at all. An org that renamed its Admin role keeps both defaults — the
# pre-existing ambiguity, not a regression.
op.execute(
"""
UPDATE roles SET is_default = false
WHERE name = 'Admin'
AND is_default = true
AND organization_id IN (
SELECT organization_id FROM roles
WHERE is_default = true
GROUP BY organization_id
HAVING COUNT(*) > 1
)
"""
)
def downgrade() -> None:
# Drop the grants first (FK), then the permissions themselves. is_default is
# not restored: the pre-migration state was ambiguous by definition.
op.execute(
"""
DELETE FROM role_permissions
WHERE permission_id IN (
SELECT id FROM permissions
WHERE name IN ('view_unassigned_chats', 'view_people')
)
"""
)
op.execute(
"""
DELETE FROM permissions
WHERE name IN ('view_unassigned_chats', 'view_people')
"""
)
@@ -0,0 +1,72 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""removed node specific fields
Revision ID: bdaa7a5d6109
Revises: dd0e5ceefe4b
Create Date: 2025-07-23 12:06:47.901308
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'bdaa7a5d6109'
down_revision: Union[str, None] = 'dd0e5ceefe4b'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint('workflow_nodes_model_id_fkey', 'workflow_nodes', type_='foreignkey')
op.drop_column('workflow_nodes', 'action_type')
op.drop_column('workflow_nodes', 'form_fields')
op.drop_column('workflow_nodes', 'model_id')
op.drop_column('workflow_nodes', 'system_prompt')
op.drop_column('workflow_nodes', 'condition_expression')
op.drop_column('workflow_nodes', 'wait_until_condition')
op.drop_column('workflow_nodes', 'action_config')
op.drop_column('workflow_nodes', 'temperature')
op.drop_column('workflow_nodes', 'wait_duration')
op.drop_column('workflow_nodes', 'transfer_rules')
op.drop_column('workflow_nodes', 'landing_page_content')
op.drop_column('workflow_nodes', 'landing_page_heading')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('workflow_nodes', sa.Column('landing_page_heading', sa.TEXT(), autoincrement=False, nullable=True))
op.add_column('workflow_nodes', sa.Column('landing_page_content', sa.TEXT(), autoincrement=False, nullable=True))
op.add_column('workflow_nodes', sa.Column('transfer_rules', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=True))
op.add_column('workflow_nodes', sa.Column('wait_duration', sa.INTEGER(), autoincrement=False, nullable=True))
op.add_column('workflow_nodes', sa.Column('temperature', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True))
op.add_column('workflow_nodes', sa.Column('action_config', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=True))
op.add_column('workflow_nodes', sa.Column('wait_until_condition', sa.TEXT(), autoincrement=False, nullable=True))
op.add_column('workflow_nodes', sa.Column('condition_expression', sa.TEXT(), autoincrement=False, nullable=True))
op.add_column('workflow_nodes', sa.Column('system_prompt', sa.TEXT(), autoincrement=False, nullable=True))
op.add_column('workflow_nodes', sa.Column('model_id', sa.INTEGER(), autoincrement=False, nullable=True))
op.add_column('workflow_nodes', sa.Column('form_fields', postgresql.JSON(astext_type=sa.Text()), autoincrement=False, nullable=True))
op.add_column('workflow_nodes', sa.Column('action_type', sa.VARCHAR(), autoincrement=False, nullable=True))
op.create_foreign_key('workflow_nodes_model_id_fkey', 'workflow_nodes', 'ai_configs', ['model_id'], ['id'])
# ### end Alembic commands ###
@@ -0,0 +1,71 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""add premium chat styles and show_citations to agent_customizations
Adds the new GLASS/TERMINAL/PLAYFUL/CALM_MINT values to the `chatstyle` enum and a
`show_citations` boolean column.
Revision ID: c1d2e3f4a5b6
Revises: add_sentiment_analysis_001
Create Date: 2026-06-26 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'c1d2e3f4a5b6'
down_revision: Union[str, None] = 'add_sentiment_analysis_001'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
NEW_CHAT_STYLES = ("GLASS", "TERMINAL", "PLAYFUL", "CALM_MINT")
def upgrade() -> None:
bind = op.get_bind()
# Add new values to the native Postgres `chatstyle` enum. ALTER TYPE ... ADD VALUE
# cannot run inside a transaction block, so use an autocommit block.
if bind.dialect.name == "postgresql":
with op.get_context().autocommit_block():
for value in NEW_CHAT_STYLES:
op.execute(f"ALTER TYPE chatstyle ADD VALUE IF NOT EXISTS '{value}'")
# Add show_citations column (default OFF for now — citations need more work).
# Use IF NOT EXISTS on Postgres so the migration is safe to run on a DB where the
# column was already added out-of-band.
if bind.dialect.name == "postgresql":
op.execute(
"ALTER TABLE agent_customizations "
"ADD COLUMN IF NOT EXISTS show_citations boolean NOT NULL DEFAULT false"
)
else:
op.add_column(
'agent_customizations',
sa.Column('show_citations', sa.Boolean(), server_default=sa.false(), nullable=False),
)
def downgrade() -> None:
op.drop_column('agent_customizations', 'show_citations')
# Postgres cannot cleanly drop individual enum values; the added chatstyle
# values are intentionally left in place.
@@ -0,0 +1,123 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""add lead capture (configs, responses, customer lead stage)
Revision ID: c3d4e5f6a7b8
Revises: b2c3d4e5f6a7
Create Date: 2026-07-07 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'c3d4e5f6a7b8'
down_revision: Union[str, None] = 'b2c3d4e5f6a7'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# Enum member NAMES are what SQLAlchemy stores (matches agenttype/sessionstatus convention).
# create_type=False + an explicit .create(checkfirst=True) so create_table/add_column
# reference the type without emitting a second (empty) CREATE TYPE.
assign_col = postgresql.ENUM('NONE', 'SALES_TEAM', 'SPECIFIC_PERSON', 'ROUND_ROBIN', name='leadassignmentmode', create_type=False)
crm_col = postgresql.ENUM('NONE', 'HUBSPOT', 'SALESFORCE', name='crmsynctarget', create_type=False)
stage_col = postgresql.ENUM('VISITOR', 'LEAD', 'CUSTOMER', name='leadstage', create_type=False)
def upgrade() -> None:
bind = op.get_bind()
# Create enum types up front (idempotent) so create_table / add_column can reference
# them without re-emitting CREATE TYPE.
assign_col.create(bind, checkfirst=True)
crm_col.create(bind, checkfirst=True)
stage_col.create(bind, checkfirst=True)
# ---- lead_capture_configs (1:1 with agents) ----
op.create_table(
'lead_capture_configs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('agent_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('enabled', sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column('require_consent', sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column('guidance', sa.Text(), nullable=True),
sa.Column('fields', sa.JSON(), nullable=True),
sa.Column('assignment_mode', assign_col, nullable=False, server_default='NONE'),
sa.Column('assignment_target_user_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('crm_sync_target', crm_col, nullable=False, server_default='NONE'),
sa.Column('slack_notify_enabled', sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['agent_id'], ['agents.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['assignment_target_user_id'], ['users.id']),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('agent_id', name='uix_lead_capture_config_agent'),
)
op.create_index(op.f('ix_lead_capture_configs_id'), 'lead_capture_configs', ['id'], unique=False)
# ---- lead_capture_responses (one row per capture attempt) ----
op.create_table(
'lead_capture_responses',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('organization_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('agent_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('customer_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('session_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('field_values', sa.JSON(), nullable=True),
sa.Column('summary', sa.Text(), nullable=True),
sa.Column('consent', sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column('qualified', sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id']),
sa.ForeignKeyConstraint(['agent_id'], ['agents.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['customer_id'], ['customers.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['session_id'], ['session_to_agents.session_id']),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_lead_capture_responses_customer_id'), 'lead_capture_responses', ['customer_id'], unique=False)
op.create_index(op.f('ix_lead_capture_responses_agent_id'), 'lead_capture_responses', ['agent_id'], unique=False)
# ---- customers: lead lifecycle columns ----
# Add lead_stage nullable first, backfill, then enforce NOT NULL (template pattern).
op.add_column('customers', sa.Column('lead_stage', stage_col, nullable=True))
op.add_column('customers', sa.Column('lead_source', sa.JSON(), nullable=True))
op.add_column('customers', sa.Column('lead_qualified_at', sa.DateTime(timezone=True), nullable=True))
op.execute("UPDATE customers SET lead_stage = 'VISITOR' WHERE lead_stage IS NULL")
op.alter_column('customers', 'lead_stage', nullable=False, server_default='VISITOR')
def downgrade() -> None:
op.drop_column('customers', 'lead_qualified_at')
op.drop_column('customers', 'lead_source')
op.drop_column('customers', 'lead_stage')
op.drop_index(op.f('ix_lead_capture_responses_agent_id'), table_name='lead_capture_responses')
op.drop_index(op.f('ix_lead_capture_responses_customer_id'), table_name='lead_capture_responses')
op.drop_table('lead_capture_responses')
op.drop_index(op.f('ix_lead_capture_configs_id'), table_name='lead_capture_configs')
op.drop_table('lead_capture_configs')
bind = op.get_bind()
stage_col.drop(bind, checkfirst=True)
crm_col.drop(bind, checkfirst=True)
assign_col.drop(bind, checkfirst=True)
@@ -0,0 +1,64 @@
"""Track channel delivery receipts on chat_history
WhatsApp reports sent / delivered / read / failed for every message we send,
keyed by its platform message id (wamid). Storing that id on the row is what
lets a receipt find its message; channel_status holds the latest receipt.
channel_status is deliberately separate from attributes.delivery_status: the
inbox reads any delivery_status as "not delivered", so a 'read' written there
would show as a failure.
Revision ID: chat_channel_status_001
Revises: idx_kq_org_status_001
Create Date: 2026-10-11
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
# Keep revision ids under 32 characters: alembic_version.version_num is
# varchar(32).
revision: str = 'chat_channel_status_001'
down_revision: Union[str, None] = 'idx_kq_org_status_001'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
INDEX_NAME = 'ix_chat_history_org_external_message'
def upgrade() -> None:
# if_not_exists throughout: the columns commit before the index builds
# (CONCURRENTLY can't share their transaction), so a start that dies
# mid-build must be able to run this again.
op.add_column('chat_history', sa.Column('external_message_id', sa.String(), nullable=True),
if_not_exists=True)
op.add_column('chat_history', sa.Column('channel_status', sa.String(16), nullable=True),
if_not_exists=True)
op.add_column('chat_history', sa.Column('channel_status_at', sa.TIMESTAMP(timezone=True),
nullable=True),
if_not_exists=True)
# Partial: only channel messages carry an id, so widget rows stay out of it.
# Built CONCURRENTLY so a large chat_history keeps taking writes while it
# builds — migrations run on backend start. That can't run in a
# transaction, hence the autocommit block. A build that fails part-way
# leaves an INVALID index that IF NOT EXISTS would then skip: if that
# happens, DROP INDEX ix_chat_history_org_external_message and restart.
with op.get_context().autocommit_block():
op.create_index(
INDEX_NAME, 'chat_history', ['organization_id', 'external_message_id'],
unique=False,
postgresql_where=sa.text('external_message_id IS NOT NULL'),
postgresql_concurrently=True,
if_not_exists=True,
)
def downgrade() -> None:
with op.get_context().autocommit_block():
op.drop_index(INDEX_NAME, table_name='chat_history',
postgresql_concurrently=True, if_exists=True)
op.drop_column('chat_history', 'channel_status_at', if_exists=True)
op.drop_column('chat_history', 'channel_status', if_exists=True)
op.drop_column('chat_history', 'external_message_id', if_exists=True)
@@ -0,0 +1,62 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""convert crm_sync_target to varchar so new CRMs need no migration
Revision ID: crm_sync_varchar_001
Revises: merge_tkt_csat_001
Create Date: 2026-07-22 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'crm_sync_varchar_001'
down_revision: Union[str, None] = 'merge_tkt_csat_001'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# The original crmsynctarget enum stored member NAMES; the varchar column stores
# lowercase enum VALUES ('none', 'hubspot', ...) to match ChannelType convention.
_ORIGINAL_ENUM_LABELS = ('NONE', 'HUBSPOT', 'SALESFORCE')
def upgrade() -> None:
op.execute("ALTER TABLE lead_capture_configs ALTER COLUMN crm_sync_target DROP DEFAULT")
op.execute(
"ALTER TABLE lead_capture_configs "
"ALTER COLUMN crm_sync_target TYPE varchar(32) "
"USING lower(crm_sync_target::text)"
)
op.execute("ALTER TABLE lead_capture_configs ALTER COLUMN crm_sync_target SET DEFAULT 'none'")
op.execute("DROP TYPE IF EXISTS crmsynctarget")
def downgrade() -> None:
labels = ", ".join(f"'{label}'" for label in _ORIGINAL_ENUM_LABELS)
op.execute(f"CREATE TYPE crmsynctarget AS ENUM ({labels})")
op.execute("ALTER TABLE lead_capture_configs ALTER COLUMN crm_sync_target DROP DEFAULT")
# Values unknown to the original enum (e.g. 'pipedrive') fold to 'NONE'.
op.execute(
"ALTER TABLE lead_capture_configs "
"ALTER COLUMN crm_sync_target TYPE crmsynctarget "
f"USING (CASE WHEN upper(crm_sync_target) IN ({labels}) "
"THEN upper(crm_sync_target) ELSE 'NONE' END)::crmsynctarget"
)
op.execute("ALTER TABLE lead_capture_configs ALTER COLUMN crm_sync_target SET DEFAULT 'NONE'")
@@ -0,0 +1,67 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""add contact-collection columns (collect_email + handoff_collect_email/name)
Adds:
- agent_customizations.collect_email (default false) -> optional pre-chat email gate
- agents.handoff_collect_email (default true) -> ask email on human handoff
- agents.handoff_collect_name (default true) -> ask name on human handoff
Revision ID: d2e3f4a5b6c7
Revises: c1d2e3f4a5b6
Create Date: 2026-06-29 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'd2e3f4a5b6c7'
down_revision: Union[str, None] = 'c1d2e3f4a5b6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
if bind.dialect.name == "postgresql":
# IF NOT EXISTS so the migration is safe on a DB where columns were added out-of-band
op.execute(
"ALTER TABLE agent_customizations "
"ADD COLUMN IF NOT EXISTS collect_email boolean NOT NULL DEFAULT false"
)
op.execute(
"ALTER TABLE agents "
"ADD COLUMN IF NOT EXISTS handoff_collect_email boolean NOT NULL DEFAULT true"
)
op.execute(
"ALTER TABLE agents "
"ADD COLUMN IF NOT EXISTS handoff_collect_name boolean NOT NULL DEFAULT true"
)
else:
op.add_column('agent_customizations', sa.Column('collect_email', sa.Boolean(), server_default=sa.false(), nullable=False))
op.add_column('agents', sa.Column('handoff_collect_email', sa.Boolean(), server_default=sa.true(), nullable=False))
op.add_column('agents', sa.Column('handoff_collect_name', sa.Boolean(), server_default=sa.true(), nullable=False))
def downgrade() -> None:
op.drop_column('agents', 'handoff_collect_name')
op.drop_column('agents', 'handoff_collect_email')
op.drop_column('agent_customizations', 'collect_email')
@@ -0,0 +1,37 @@
"""add customers.merged_into_customer_id for visitor→customer merge
Revision ID: d4e5f6a7b8c9
Revises: c3d4e5f6a7b8
Create Date: 2026-07-08
When an anonymous widget visitor shares an email that already belongs to an
existing customer in the org, the visitor row is merged into that customer.
The old row keeps a pointer so stale device tokens resolve to the target.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'd4e5f6a7b8c9'
down_revision = 'c3d4e5f6a7b8'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
'customers',
sa.Column('merged_into_customer_id', postgresql.UUID(as_uuid=True), nullable=True),
)
op.create_foreign_key(
'fk_customers_merged_into', 'customers', 'customers',
['merged_into_customer_id'], ['id'],
)
op.create_index('ix_customers_merged_into', 'customers', ['merged_into_customer_id'])
def downgrade() -> None:
op.drop_index('ix_customers_merged_into', table_name='customers')
op.drop_constraint('fk_customers_merged_into', 'customers', type_='foreignkey')
op.drop_column('customers', 'merged_into_customer_id')
@@ -0,0 +1,64 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""add_attempts_to_otp_table
Revision ID: d9a4b9902f80
Revises: 013174e15242
Create Date: 2025-10-07 19:24:50.790908
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'd9a4b9902f80'
down_revision: Union[str, None] = '013174e15242'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Check if enterprise schema exists before adding column
# In open-source version, enterprise schema doesn't exist
connection = op.get_bind()
# Check if enterprise schema exists
schema_exists = connection.execute(
sa.text("SELECT EXISTS(SELECT 1 FROM information_schema.schemata WHERE schema_name = 'enterprise')")
).scalar()
if schema_exists:
# Add attempts column to otps table in enterprise schema
op.add_column('otps', sa.Column('attempts', sa.Integer(), nullable=False, server_default='0'), schema='enterprise')
def downgrade() -> None:
# Check if enterprise schema exists before removing column
# In open-source version, enterprise schema doesn't exist
connection = op.get_bind()
# Check if enterprise schema exists
schema_exists = connection.execute(
sa.text("SELECT EXISTS(SELECT 1 FROM information_schema.schemata WHERE schema_name = 'enterprise')")
).scalar()
if schema_exists:
# Remove attempts column from otps table in enterprise schema
op.drop_column('otps', 'attempts', schema='enterprise')
@@ -0,0 +1,52 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""add_progress_tracking_to_knowledge_queue
Revision ID: dc827ab30bd4
Revises: 621095beb234
Create Date: 2025-08-05 16:28:47.201062
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'dc827ab30bd4'
down_revision: Union[str, None] = '621095beb234'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add new progress tracking columns to knowledge_queue table
op.add_column('knowledge_queue', sa.Column('processing_stage', sa.String(), nullable=True, server_default='not_started'))
op.add_column('knowledge_queue', sa.Column('progress_percentage', sa.Float(), nullable=True, server_default='0.0'))
op.add_column('knowledge_queue', sa.Column('total_items', sa.Integer(), nullable=True, server_default='0'))
op.add_column('knowledge_queue', sa.Column('processed_items', sa.Integer(), nullable=True, server_default='0'))
op.add_column('knowledge_queue', sa.Column('crawled_urls', sa.JSON(), nullable=True))
def downgrade() -> None:
# Remove the progress tracking columns
op.drop_column('knowledge_queue', 'crawled_urls')
op.drop_column('knowledge_queue', 'processing_stage')
op.drop_column('knowledge_queue', 'progress_percentage')
op.drop_column('knowledge_queue', 'total_items')
op.drop_column('knowledge_queue', 'processed_items')
@@ -0,0 +1,72 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""added new node type
Revision ID: dd0e5ceefe4b
Revises: 7fd0447b3d45
Create Date: 2025-07-18 12:33:17.762285
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'dd0e5ceefe4b'
down_revision: Union[str, None] = '7fd0447b3d45'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# Update the NodeType enum to include the new 'landing_page' type
op.execute("ALTER TYPE nodetype ADD VALUE IF NOT EXISTS 'LANDING_PAGE'")
# Add new columns for landing page node type
op.add_column('workflow_nodes', sa.Column('landing_page_heading', sa.Text(), nullable=True))
op.add_column('workflow_nodes', sa.Column('landing_page_content', sa.Text(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# First drop the columns that use the enum
op.drop_column('workflow_nodes', 'landing_page_content')
op.drop_column('workflow_nodes', 'landing_page_heading')
# Create a new enum without the landing_page value
op.execute("""
CREATE TYPE nodetype_new AS ENUM (
'MESSAGE','LLM','CONDITION','FORM','ACTION','HUMAN_TRANSFER','WAIT','END'
)
""")
# Update the column type to use the new enum
op.execute("ALTER TABLE workflow_nodes ALTER COLUMN node_type TYPE nodetype_new USING node_type::text::nodetype_new")
# Drop the old enum
op.execute("DROP TYPE nodetype")
# Rename the new enum to the original name
op.execute("ALTER TYPE nodetype_new RENAME TO nodetype")
# ### end Alembic commands ###
@@ -0,0 +1,60 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""Drop legacy Slack tables — Slack now runs on the unified channel stack
Revision ID: drop_slack_tables_001
Revises: add_channel_tables_001
Create Date: 2026-07-10 22:00:00.000000
The old integration (slack_tokens with plaintext tokens, per-thread
slack_conversations, per-channel agent_slack_configs, workspace storage modes)
is replaced by channel_accounts / channel_conversations / agent_channel_configs.
The old integration had no production users, so no data is migrated. Downgrade
recreates empty tables via the original add_slack_integration_001 shapes.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'drop_slack_tables_001'
down_revision: Union[str, None] = 'add_channel_tables_001'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_TABLES = ('slack_conversations', 'agent_slack_configs', 'slack_workspace_configs', 'slack_tokens')
def upgrade() -> None:
for table in _TABLES:
op.execute(f'DROP TABLE IF EXISTS {table} CASCADE')
op.execute('DROP TYPE IF EXISTS storagemode')
def downgrade() -> None:
# Recreate empty legacy tables by re-running the original migration's DDL.
# Version scripts aren't importable by module name — load by file path.
import importlib.util
import os
legacy_path = os.path.join(os.path.dirname(__file__), 'add_slack_integration_001.py')
spec = importlib.util.spec_from_file_location('add_slack_integration_001', legacy_path)
legacy = importlib.util.module_from_spec(spec)
spec.loader.exec_module(legacy)
legacy.upgrade()
@@ -0,0 +1,51 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""Agent created date added
Revision ID: e252efacd083
Revises: 9a01e6b653b2
Create Date: 2025-07-09 10:30:11.970669
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e252efacd083'
down_revision: Union[str, None] = '9a01e6b653b2'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('agents', sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True))
op.add_column('agents', sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('agents', 'updated_at')
op.drop_column('agents', 'created_at')
# ### end Alembic commands ###
@@ -0,0 +1,54 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""add AURORA chat style to agent_customizations
Adds the new AURORA value to the `chatstyle` enum (a dark "ask-me-anything"
design with a gradient orb avatar). The orb-vs-photo avatar choice is stored in
the existing `customization_metadata` JSON column, so no schema change is needed
for that.
Revision ID: e3f4a5b6c7d8
Revises: d2e3f4a5b6c7
Create Date: 2026-06-29 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'e3f4a5b6c7d8'
down_revision: Union[str, None] = 'd2e3f4a5b6c7'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
# Add the new value to the native Postgres `chatstyle` enum. ALTER TYPE ...
# ADD VALUE cannot run inside a transaction block, so use an autocommit block.
if bind.dialect.name == "postgresql":
with op.get_context().autocommit_block():
op.execute("ALTER TYPE chatstyle ADD VALUE IF NOT EXISTS 'AURORA'")
def downgrade() -> None:
# Postgres cannot cleanly drop individual enum values; the added chatstyle
# value is intentionally left in place.
pass
@@ -0,0 +1,32 @@
"""add customers.is_authenticated to mark integration-authenticated people
Revision ID: e5f6a7b8c9d0
Revises: d4e5f6a7b8c9
Create Date: 2026-07-09
People who were identified via POST /generate-token (the embedding app asserted a
known customer_email) are the business's existing customers, not organic leads, and
must be excluded from the People/leads views. This was previously inferred from
meta_data being NULL, but SQLAlchemy stores an empty JSON value as JSON null rather
than SQL NULL, so `meta_data IS NULL` matched nothing and hid every visitor/lead.
Replace that heuristic with an explicit boolean flag.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'e5f6a7b8c9d0'
down_revision = 'd4e5f6a7b8c9'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
'customers',
sa.Column('is_authenticated', sa.Boolean(), nullable=False, server_default='false'),
)
def downgrade() -> None:
op.drop_column('customers', 'is_authenticated')
@@ -0,0 +1,61 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""add jira agent config
Revision ID: e65e1b8e7e3c
Revises: 30caca239191
Create Date: 2025-03-06 21:51:54.214580
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e65e1b8e7e3c'
down_revision: Union[str, None] = '30caca239191'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('agent_jira_configs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('agent_id', sa.String(), nullable=False),
sa.Column('enabled', sa.Boolean(), nullable=False),
sa.Column('project_key', sa.String(), nullable=True),
sa.Column('issue_type_id', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_agent_jira_configs_agent_id'), 'agent_jira_configs', ['agent_id'], unique=False)
op.create_index(op.f('ix_agent_jira_configs_id'), 'agent_jira_configs', ['id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_agent_jira_configs_id'), table_name='agent_jira_configs')
op.drop_index(op.f('ix_agent_jira_configs_agent_id'), table_name='agent_jira_configs')
op.drop_table('agent_jira_configs')
# ### end Alembic commands ###
@@ -0,0 +1,52 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""Add rate limiting fields to Agent model
Revision ID: f3dad24474e0
Revises: ff48f854bcbf
Create Date: 2025-03-15 20:44:17.465349
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'f3dad24474e0'
down_revision: Union[str, None] = 'ff48f854bcbf'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('agents', sa.Column('enable_rate_limiting', sa.Boolean(), nullable=True, default=False))
op.add_column('agents', sa.Column('overall_limit_per_ip', sa.Integer(), nullable=True, default=100))
op.add_column('agents', sa.Column('requests_per_sec', sa.Float(), nullable=True, default=1))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('agents', 'requests_per_sec')
op.drop_column('agents', 'overall_limit_per_ip')
op.drop_column('agents', 'enable_rate_limiting')
# ### end Alembic commands ###
@@ -0,0 +1,65 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""add SUNRISE chat style + welcome_message and quick_actions columns
Adds:
- SUNRISE value to the `chatstyle` enum (a light theme).
- `welcome_message` (Text): first in-conversation agent bubble shown on open.
- `quick_actions` (JSON): predefined quick-action button labels.
Revision ID: f4a5b6c7d8e9
Revises: e3f4a5b6c7d8
Create Date: 2026-06-30 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'f4a5b6c7d8e9'
down_revision: Union[str, None] = 'e3f4a5b6c7d8'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
# Add the new value to the native Postgres `chatstyle` enum. ALTER TYPE ...
# ADD VALUE cannot run inside a transaction block, so use an autocommit block.
if bind.dialect.name == "postgresql":
with op.get_context().autocommit_block():
op.execute("ALTER TYPE chatstyle ADD VALUE IF NOT EXISTS 'SUNRISE'")
op.add_column(
'agent_customizations',
sa.Column('welcome_message', sa.Text(), nullable=True)
)
op.add_column(
'agent_customizations',
sa.Column('quick_actions', sa.JSON(), nullable=True)
)
def downgrade() -> None:
op.drop_column('agent_customizations', 'quick_actions')
op.drop_column('agent_customizations', 'welcome_message')
# Postgres cannot cleanly drop individual enum values; the added chatstyle
# value is intentionally left in place.
@@ -0,0 +1,182 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""add FAQ + public help center tables
Revision ID: f7a1c2d3e4b5
Revises: drop_slack_tables_001
Create Date: 2026-07-12
New feature: AI-generated FAQs reviewed/published by the org and served as a
public help center on {slug}.help.replypier.site or a verified custom domain.
Tables:
- faq_generation_jobs: async generate/import jobs (worker + UI polling)
- help_center_settings: per-org branding, slug, agent mapping, custom domain
- faqs: the question/answer pairs (draft -> published workflow)
- help_center_queries: public "Ask AI" log for metering/analytics
Status/stage columns are plain strings (mirroring knowledge_queue) so new
values never need an ALTER TYPE migration.
Also extends the notificationtype enum with FAQ job outcomes. Labels are the
uppercase member names, matching how the enum was created in
b365b8447598_initial_version. ALTER TYPE ... ADD VALUE requires PostgreSQL 12+
to run inside a transaction; added values cannot be removed on downgrade.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'f7a1c2d3e4b5'
down_revision = 'drop_slack_tables_001'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
'faq_generation_jobs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('organization_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('job_type', sa.String(), nullable=False),
sa.Column('knowledge_id', sa.Integer(), nullable=True),
sa.Column('source_url', sa.String(), nullable=True),
sa.Column('status', sa.String(), nullable=False),
sa.Column('stage', sa.String(), nullable=False),
sa.Column('progress_percentage', sa.Float(), nullable=False),
sa.Column('faqs_created', sa.Integer(), nullable=False),
sa.Column('error', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['knowledge_id'], ['knowledge.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_faq_generation_jobs_id'), 'faq_generation_jobs', ['id'])
op.create_index(op.f('ix_faq_generation_jobs_organization_id'), 'faq_generation_jobs', ['organization_id'])
op.create_index(op.f('ix_faq_generation_jobs_knowledge_id'), 'faq_generation_jobs', ['knowledge_id'])
# The worker polls for pending/processing rows forever; keep that lookup
# O(active) as terminal rows accumulate.
op.create_index(
'ix_faq_generation_jobs_active',
'faq_generation_jobs',
['status'],
postgresql_where=sa.text("status IN ('pending', 'processing')"),
)
# DB-enforced one-active-job guard per (org, type, source): the API's
# check-then-insert would otherwise race on concurrent /generate calls.
# COALESCE keeps per-source auto jobs for different sources parallel.
op.create_index(
'uq_faq_generation_jobs_one_active',
'faq_generation_jobs',
['organization_id', 'job_type', sa.text('COALESCE(knowledge_id, -1)')],
unique=True,
postgresql_where=sa.text("status IN ('pending', 'processing')"),
)
op.create_table(
'help_center_settings',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('organization_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('enabled', sa.Boolean(), nullable=False),
sa.Column('slug', sa.String(length=63), nullable=True),
sa.Column('title', sa.String(length=120), nullable=True),
sa.Column('description', sa.String(length=300), nullable=True),
sa.Column('logo_url', sa.String(), nullable=True),
sa.Column('brand_color', sa.String(length=9), nullable=False),
sa.Column('header_links', sa.JSON(), nullable=False),
sa.Column('cta_text', sa.String(length=40), nullable=True),
sa.Column('cta_url', sa.String(), nullable=True),
sa.Column('auto_generate', sa.Boolean(), nullable=False),
sa.Column('agent_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('ai_search_enabled', sa.Boolean(), nullable=False),
sa.Column('custom_domain', sa.String(length=255), nullable=True),
sa.Column('domain_verification_token', sa.String(length=64), nullable=True),
sa.Column('txt_record_verified', sa.Boolean(), nullable=False),
sa.Column('cname_record_verified', sa.Boolean(), nullable=False),
sa.Column('ssl_status', sa.String(), nullable=False),
sa.Column('domain_verified_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['agent_id'], ['agents.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('organization_id'),
sa.UniqueConstraint('slug'),
sa.UniqueConstraint('custom_domain'),
sa.CheckConstraint('slug = lower(slug)', name='ck_help_center_slug_lowercase'),
)
op.create_index(op.f('ix_help_center_settings_slug'), 'help_center_settings', ['slug'])
op.create_index(op.f('ix_help_center_settings_custom_domain'), 'help_center_settings', ['custom_domain'])
op.create_table(
'faqs',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('organization_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('question', sa.Text(), nullable=False),
sa.Column('answer', sa.Text(), nullable=False),
sa.Column('category', sa.String(length=100), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('knowledge_id', sa.Integer(), nullable=True),
sa.Column('source_label', sa.String(length=255), nullable=True),
sa.Column('generation_job_id', sa.Integer(), nullable=True),
sa.Column('created_by', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('sort_order', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['knowledge_id'], ['knowledge.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['generation_job_id'], ['faq_generation_jobs.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id'),
)
# Composite indexes lead with organization_id, so no single-column org index.
op.create_index('ix_faqs_org_status', 'faqs', ['organization_id', 'status'])
op.create_index('ix_faqs_org_category', 'faqs', ['organization_id', 'category'])
# FK indexes so knowledge/job deletions don't scan faqs to enforce SET NULL.
op.create_index(op.f('ix_faqs_knowledge_id'), 'faqs', ['knowledge_id'])
op.create_index(op.f('ix_faqs_generation_job_id'), 'faqs', ['generation_job_id'])
op.create_table(
'help_center_queries',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('organization_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('query', sa.Text(), nullable=False),
sa.Column('answered', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_help_center_queries_organization_id'), 'help_center_queries', ['organization_id'])
op.create_index(op.f('ix_help_center_queries_created_at'), 'help_center_queries', ['created_at'])
op.execute("ALTER TYPE notificationtype ADD VALUE IF NOT EXISTS 'FAQ_GENERATED'")
op.execute("ALTER TYPE notificationtype ADD VALUE IF NOT EXISTS 'FAQ_GENERATION_FAILED'")
def downgrade() -> None:
# The enum values added in upgrade() cannot be removed; delete the rows
# that reference them so the reverted application enum can still load
# every remaining notification.
op.execute("DELETE FROM notifications WHERE type IN ('FAQ_GENERATED', 'FAQ_GENERATION_FAILED')")
op.drop_table('help_center_queries')
op.drop_table('faqs')
op.drop_table('help_center_settings')
op.drop_table('faq_generation_jobs')
@@ -0,0 +1,50 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""added user id in rating table
Revision ID: fa04f644fc0c
Revises: 186c65ae9406
Create Date: 2025-03-05 15:38:12.395283
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'fa04f644fc0c'
down_revision: Union[str, None] = '186c65ae9406'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('ratings', sa.Column('user_id', sa.UUID(), nullable=True))
op.create_foreign_key(None, 'ratings', 'users', ['user_id'], ['id'], ondelete='SET NULL')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'ratings', type_='foreignkey')
op.drop_column('ratings', 'user_id')
# ### end Alembic commands ###
@@ -0,0 +1,88 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""per-device fcm_tokens table, replacing users.fcm_token_web
Revision ID: fcm_tokens_per_device_001
Revises: add_agent_ai_replies_001
Create Date: 2026-08-20
The old single column held one push token for the whole account, so signing in
on a second device overwrote the first one's token and signing out anywhere
cleared it for every device. Existing tokens are carried over, one row each, so
already-registered browsers keep receiving pushes across the deploy.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'fcm_tokens_per_device_001'
down_revision = 'add_agent_ai_replies_001'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
'fcm_tokens',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('token', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True),
server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True),
server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('token', name='uq_fcm_tokens_token'),
)
op.create_index('ix_fcm_tokens_user_id', 'fcm_tokens', ['user_id'])
# Carry over the tokens already registered. DISTINCT ON keeps the unique
# constraint safe in the unlikely case two accounts share a token — the
# most recently updated row wins, which is the one FCM would deliver to.
op.execute(
"""
INSERT INTO fcm_tokens (id, user_id, token)
SELECT DISTINCT ON (fcm_token_web) gen_random_uuid(), id, fcm_token_web
FROM users
WHERE fcm_token_web IS NOT NULL AND fcm_token_web <> ''
ORDER BY fcm_token_web, updated_at DESC NULLS LAST
"""
)
op.drop_column('users', 'fcm_token_web')
def downgrade() -> None:
op.add_column('users', sa.Column(
'fcm_token_web', sa.String(), nullable=True))
# Only one token per user fits in the old column; keep the newest.
op.execute(
"""
UPDATE users u
SET fcm_token_web = t.token
FROM (
SELECT DISTINCT ON (user_id) user_id, token
FROM fcm_tokens
ORDER BY user_id, created_at DESC
) t
WHERE t.user_id = u.id
"""
)
op.drop_index('ix_fcm_tokens_user_id', table_name='fcm_tokens')
op.drop_table('fcm_tokens')
@@ -0,0 +1,58 @@
"""
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
"""add_ticket_fields_to_session
Revision ID: ff48f854bcbf
Revises: e65e1b8e7e3c
Create Date: 2025-03-07 11:09:21.933786
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'ff48f854bcbf'
down_revision: Union[str, None] = 'e65e1b8e7e3c'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('session_to_agents', sa.Column('ticket_id', sa.String(), nullable=True))
op.add_column('session_to_agents', sa.Column('ticket_status', sa.String(), nullable=True))
op.add_column('session_to_agents', sa.Column('ticket_summary', sa.String(), nullable=True))
op.add_column('session_to_agents', sa.Column('ticket_description', sa.String(), nullable=True))
op.add_column('session_to_agents', sa.Column('integration_type', sa.String(), nullable=True))
op.add_column('session_to_agents', sa.Column('ticket_priority', sa.String(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('session_to_agents', 'ticket_priority')
op.drop_column('session_to_agents', 'integration_type')
op.drop_column('session_to_agents', 'ticket_description')
op.drop_column('session_to_agents', 'ticket_summary')
op.drop_column('session_to_agents', 'ticket_status')
op.drop_column('session_to_agents', 'ticket_id')
# ### end Alembic commands ###
Loaded 100 of 167 files, more files were not shown because too many files have changed in this diff. Show more