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

+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 ###
@@ -0,0 +1,44 @@
"""Repoint baked absolute S3 article-image URLs at the delivery route
Revision ID: hc_img_paths_001
Revises: add_notif_prefs_001
Create Date: 2026-07-28
Article images used to be baked into faqs.answer as absolute S3 URLs. Those
only ever worked while the bucket was publicly readable; on a correctly private
bucket they 403. Article Markdown cannot hold a signed URL either, since it is
stored permanently and signatures expire. Rewrite them to the stable
/api/v1/help-center/images/<name> path, which is resolved per request against
whichever storage backend is configured. Local /api/v1/uploads/ paths and
non-S3 URLs are left untouched.
Irreversible best-effort — downgrade is a no-op.
"""
from alembic import op
import sqlalchemy as sa
from app.services.help_center_images import rewrite_baked_s3_image_urls
revision = "hc_img_paths_001"
down_revision = "add_notif_prefs_001"
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
rows = bind.execute(
sa.text("SELECT id, answer FROM faqs WHERE answer LIKE '%amazonaws.com%'")
).fetchall()
for row in rows:
new_answer = rewrite_baked_s3_image_urls(row.answer)
if new_answer != row.answer:
bind.execute(
sa.text("UPDATE faqs SET answer = :val WHERE id = :id"),
{"val": new_answer, "id": row.id},
)
def downgrade() -> None:
# The original bucket/region are not recoverable from the rewritten path,
# and the new path works on every backend anyway.
pass
@@ -0,0 +1,35 @@
"""Index knowledge_queue by organization and status
The plan's source limit now counts the crawls still queued or running, so
every file upload and URL add reads this table. It had no index beyond the
primary key and priority, and nothing purges completed rows, so that read
was a sequential scan of every tenant's history.
Revision ID: idx_kq_org_status_001
Revises: add_mcp_usage_guidance_001
Create Date: 2026-09-18
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
# Keep revision ids under 32 characters: alembic_version.version_num is
# varchar(32), and a longer id upgrades the schema then fails to record
# itself, so every later start retries and crashes.
revision: str = 'idx_kq_org_status_001'
down_revision: Union[str, None] = 'add_mcp_usage_guidance_001'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
INDEX_NAME = 'ix_knowledge_queue_org_status'
def upgrade() -> None:
op.create_index(
INDEX_NAME, 'knowledge_queue', ['organization_id', 'status'], unique=False
)
def downgrade() -> None:
op.drop_index(INDEX_NAME, table_name='knowledge_queue')
@@ -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.
"""
"""merge the preserved-URL, allow-new-chat and upload-prefix heads
Revision ID: merge_hc_url_path_001
Revises: add_hc_faq_url_path_001, add_allow_new_chat_001, normalise_upload_prefix_001
Create Date: 2026-08-10
Three revisions branched off repair_agent_role_seed_001 independently: the
preserved help-center URLs on this branch, plus allow-new-chat and the upload
prefix normalisation, which had ALREADY left main with two heads before this
branch existed. `alembic upgrade head` in scripts/start.sh refuses to pick
between heads and fails, so the backend crash-loops on start until they are
rejoined. This no-op merge does that. The revision id is kept short —
alembic_version.version_num is varchar(32).
"""
from alembic import op # noqa: F401
import sqlalchemy as sa # noqa: F401
# revision identifiers, used by Alembic.
revision = 'merge_hc_url_path_001'
down_revision = (
'add_hc_faq_url_path_001',
'add_allow_new_chat_001',
'normalise_upload_prefix_001',
)
branch_labels = None
depends_on = 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.
"""
"""merge ticket CSAT and investigation-metering migration heads
Revision ID: merge_tkt_csat_001
Revises: add_tkt_csat_001, add_tkt_metered_001
Create Date: 2026-07-22
The CSAT work branched from add_tkt_row_scope_001 while investigation metering
branched from the same revision, leaving two alembic heads. Deploy runs
`alembic upgrade head`, which refuses to pick between them and fails the
container start, so this no-op merge rejoins them. The revision id is kept
short — alembic_version.version_num is varchar(32).
"""
from alembic import op # noqa: F401
import sqlalchemy as sa # noqa: F401
# revision identifiers, used by Alembic.
revision = 'merge_tkt_csat_001'
down_revision = ('add_tkt_csat_001', 'add_tkt_metered_001')
branch_labels = None
depends_on = 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.
"""
"""merge native-ticketing and customer-phone migration heads
Revision ID: merge_tkt_phone_001
Revises: add_investigation_runs_001, add_customer_phone_001
Create Date: 2026-07-17
The ticketing feature branched from add_hc_faq_feedback_001 at the same time
main's WhatsApp phone-identity work did, leaving two alembic heads. This
no-op merge rejoins them so `alembic upgrade head` (used by the deploy) has a
single target. The revision id is kept short — alembic_version.version_num is
varchar(32).
"""
from alembic import op # noqa: F401
import sqlalchemy as sa # noqa: F401
# revision identifiers, used by Alembic.
revision = 'merge_tkt_phone_001'
down_revision = ('add_investigation_runs_001', 'add_customer_phone_001')
branch_labels = None
depends_on = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
Loaded 100 of 167 files, more files were not shown because too many files have changed in this diff. Show more