diff --git a/alembic/versions/0119_ai_provider_compliance_fields.py b/alembic/versions/0119_ai_provider_compliance_fields.py index f503617..9418c3c 100644 --- a/alembic/versions/0119_ai_provider_compliance_fields.py +++ b/alembic/versions/0119_ai_provider_compliance_fields.py @@ -18,7 +18,26 @@ branch_labels = None depends_on = None +def _table_exists(conn, table_name: str) -> bool: + """True when the table exists (dual-path convergence, Gate B). + + On a fresh install the ai_assistant plugin SQL migration has not run + yet when Alembic reaches this revision — skip instead of failing. + The plugin-side migration adds the same columns idempotently. + """ + row = conn.execute( + sa.text("SELECT to_regclass(:tname) IS NOT NULL"), + {"tname": f"public.{table_name}"}, + ).scalar() + return bool(row) + + def upgrade() -> None: + conn = op.get_bind() + if not _table_exists(conn, "ai_providers"): + # Fresh-install path: table arrives with the ai_assistant plugin + # migration, which includes these columns. + return op.add_column("ai_providers", sa.Column("region", sa.String(20), nullable=False, server_default="unknown")) op.add_column("ai_providers", sa.Column("hosting_type", sa.String(30), nullable=False, server_default="cloud")) op.add_column("ai_providers", sa.Column("dpa_status", sa.String(20), nullable=False, server_default="none")) @@ -29,6 +48,9 @@ def upgrade() -> None: def downgrade() -> None: + conn = op.get_bind() + if not _table_exists(conn, "ai_providers"): + return op.drop_column("ai_providers", "allowed_data_classes") op.drop_column("ai_providers", "transfer_notice") op.drop_column("ai_providers", "training_on_customer_data") diff --git a/alembic/versions/0120_notification_to_comm_system_channel.py b/alembic/versions/0120_notification_to_comm_system_channel.py index fea41bb..adb061e 100644 --- a/alembic/versions/0120_notification_to_comm_system_channel.py +++ b/alembic/versions/0120_notification_to_comm_system_channel.py @@ -17,126 +17,145 @@ branch_labels = None depends_on = None +def _table_exists(conn, table_name: str) -> bool: + """True when the table exists (dual-path convergence, Gate B). + + On a fresh install the kommunikation plugin SQL migration has not run + yet when Alembic reaches this revision — skip the comm_* parts instead + of failing. The plugin-side migration adds the same column idempotently. + """ + row = conn.execute( + sa.text("SELECT to_regclass(:tname) IS NOT NULL"), + {"tname": f"public.{table_name}"}, + ).scalar() + return bool(row) + + def upgrade() -> None: - # 1. Add is_system column to comm_conversations - op.add_column( - "comm_conversations", - sa.Column("is_system", sa.Boolean(), nullable=False, server_default=sa.text("false")), - ) - op.create_index( - "ix_comm_conversations_tenant_system", - "comm_conversations", - ["tenant_id", "is_system"], - ) + conn = op.get_bind() + if _table_exists(conn, "comm_conversations"): + # 1. Add is_system column to comm_conversations + op.add_column( + "comm_conversations", + sa.Column("is_system", sa.Boolean(), nullable=False, server_default=sa.text("false")), + ) + op.create_index( + "ix_comm_conversations_tenant_system", + "comm_conversations", + ["tenant_id", "is_system"], + ) - # 2. Create system channel per tenant (for tenants that have notifications) - op.execute(""" - INSERT INTO comm_conversations (id, tenant_id, title, is_pinned, is_locked, is_direct, is_archived, is_system, created_by, created_by_type, metadata, created_at, updated_at) - SELECT - gen_random_uuid(), - n.tenant_id, - 'System Channel', - false, - true, - false, - false, - true, - NULL, - 'system', - '{}'::jsonb, - NOW(), - NOW() - FROM ( - SELECT DISTINCT tenant_id FROM notifications WHERE deleted_at IS NULL - ) n - WHERE NOT EXISTS ( - SELECT 1 FROM comm_conversations cc - WHERE cc.tenant_id = n.tenant_id AND cc.is_system = true AND cc.deleted_at IS NULL - ); - """) + # 2. Create system channel per tenant (for tenants that have notifications) + op.execute(""" + INSERT INTO comm_conversations (id, tenant_id, title, is_pinned, is_locked, is_direct, is_archived, is_system, created_by, created_by_type, metadata, created_at, updated_at) + SELECT + gen_random_uuid(), + n.tenant_id, + 'System Channel', + false, + true, + false, + false, + true, + NULL, + 'system', + '{}'::jsonb, + NOW(), + NOW() + FROM ( + SELECT DISTINCT tenant_id FROM notifications WHERE deleted_at IS NULL + ) n + WHERE NOT EXISTS ( + SELECT 1 FROM comm_conversations cc + WHERE cc.tenant_id = n.tenant_id AND cc.is_system = true AND cc.deleted_at IS NULL + ); + """) - # 3. Insert notifications as CommMessages in the system channel - op.execute(""" - INSERT INTO comm_messages (id, tenant_id, conversation_id, sender_id, sender_type, content, content_format, metadata, created_at, updated_at) - SELECT - gen_random_uuid(), - n.tenant_id, - sc.id, - n.user_id, - 'system', - COALESCE(n.title, '') || CASE WHEN n.body IS NOT NULL THEN E'\n' || n.body ELSE '' END, - 'text', - jsonb_build_object( - 'notification_type', n.type, - 'severity', 'info', - 'entity_ref', CASE WHEN n.entity_type IS NOT NULL THEN jsonb_build_object('entity_type', n.entity_type, 'entity_id', n.entity_id::text) ELSE NULL END, - 'migrated_from_notification', true, - 'original_notification_id', n.id::text - ), - n.created_at, - COALESCE(n.read_at, n.created_at) - FROM notifications n - JOIN comm_conversations sc ON sc.tenant_id = n.tenant_id AND sc.is_system = true AND sc.deleted_at IS NULL - WHERE n.deleted_at IS NULL; - """) + # 3. Insert notifications as CommMessages in the system channel + op.execute(""" + INSERT INTO comm_messages (id, tenant_id, conversation_id, sender_id, sender_type, content, content_format, metadata, created_at, updated_at) + SELECT + gen_random_uuid(), + n.tenant_id, + sc.id, + n.user_id, + 'system', + COALESCE(n.title, '') || CASE WHEN n.body IS NOT NULL THEN E'\n' || n.body ELSE '' END, + 'text', + jsonb_build_object( + 'notification_type', n.type, + 'severity', 'info', + 'entity_ref', CASE WHEN n.entity_type IS NOT NULL THEN jsonb_build_object('entity_type', n.entity_type, 'entity_id', n.entity_id::text) ELSE NULL END, + 'migrated_from_notification', true, + 'original_notification_id', n.id::text + ), + n.created_at, + COALESCE(n.read_at, n.created_at) + FROM notifications n + JOIN comm_conversations sc ON sc.tenant_id = n.tenant_id AND sc.is_system = true AND sc.deleted_at IS NULL + WHERE n.deleted_at IS NULL; + """) - # 4. Insert text blocks for each migrated message - op.execute(""" - INSERT INTO comm_message_blocks (id, tenant_id, message_id, block_type, block_data, sort_order) - SELECT - gen_random_uuid(), - cm.tenant_id, - cm.id, - 'text', - jsonb_build_object('text', cm.content), - 0 - FROM comm_messages cm - WHERE cm.metadata->>'migrated_from_notification' = 'true'; - """) + # 4. Insert text blocks for each migrated message + op.execute(""" + INSERT INTO comm_message_blocks (id, tenant_id, message_id, block_type, block_data, sort_order) + SELECT + gen_random_uuid(), + cm.tenant_id, + cm.id, + 'text', + jsonb_build_object('text', cm.content), + 0 + FROM comm_messages cm + WHERE cm.metadata->>'migrated_from_notification' = 'true'; + """) - # 5. Insert action_card blocks for messages with entity references - op.execute(""" - INSERT INTO comm_message_blocks (id, tenant_id, message_id, block_type, block_data, sort_order) - SELECT - gen_random_uuid(), - cm.tenant_id, - cm.id, - 'action_card', - jsonb_build_object( - 'label', 'Open', - 'entity_type', (cm.metadata->'entity_ref'->>'entity_type'), - 'entity_id', (cm.metadata->'entity_ref'->>'entity_id') - ), - 1 - FROM comm_messages cm - WHERE cm.metadata->>'migrated_from_notification' = 'true' - AND cm.metadata->'entity_ref' IS NOT NULL; - """) + # 5. Insert action_card blocks for messages with entity references + op.execute(""" + INSERT INTO comm_message_blocks (id, tenant_id, message_id, block_type, block_data, sort_order) + SELECT + gen_random_uuid(), + cm.tenant_id, + cm.id, + 'action_card', + jsonb_build_object( + 'label', 'Open', + 'entity_type', (cm.metadata->'entity_ref'->>'entity_type'), + 'entity_id', (cm.metadata->'entity_ref'->>'entity_id') + ), + 1 + FROM comm_messages cm + WHERE cm.metadata->>'migrated_from_notification' = 'true' + AND cm.metadata->'entity_ref' IS NOT NULL; + """) - # 6. For read notifications, create CommMessageRead entries - op.execute(""" - INSERT INTO comm_message_reads (id, tenant_id, conversation_id, user_id, last_read_msg_id, last_read_at) - SELECT - gen_random_uuid(), - cm.tenant_id, - cm.conversation_id, - cm.sender_id, - cm.id, - COALESCE(n.read_at, n.created_at) - FROM comm_messages cm - JOIN notifications n ON n.id::text = cm.metadata->>'original_notification_id' - WHERE cm.metadata->>'migrated_from_notification' = 'true' - AND n.read_at IS NOT NULL - AND n.deleted_at IS NULL; - """) + # 6. For read notifications, create CommMessageRead entries + op.execute(""" + INSERT INTO comm_message_reads (id, tenant_id, conversation_id, user_id, last_read_msg_id, last_read_at) + SELECT + gen_random_uuid(), + cm.tenant_id, + cm.conversation_id, + cm.sender_id, + cm.id, + COALESCE(n.read_at, n.created_at) + FROM comm_messages cm + JOIN notifications n ON n.id::text = cm.metadata->>'original_notification_id' + WHERE cm.metadata->>'migrated_from_notification' = 'true' + AND n.read_at IS NOT NULL + AND n.deleted_at IS NULL; + """) - # 7. Create legacy view over notifications table for backward compatibility + # 7. Legacy view over the CORE notifications table — exists on both paths op.execute("DROP VIEW IF EXISTS notifications_legacy") op.execute("CREATE VIEW notifications_legacy AS SELECT * FROM notifications") def downgrade() -> None: + conn = op.get_bind() op.execute("DROP VIEW IF EXISTS notifications_legacy") + if not _table_exists(conn, "comm_conversations"): + return op.execute("DELETE FROM comm_message_blocks WHERE message_id IN (SELECT id FROM comm_messages WHERE metadata->>'migrated_from_notification' = 'true')") op.execute("DELETE FROM comm_messages WHERE metadata->>'migrated_from_notification' = 'true'") op.execute("DELETE FROM comm_conversations WHERE is_system = true AND title = 'System Channel'") diff --git a/alembic/versions/0121_agent_run_steps.py b/alembic/versions/0121_agent_run_steps.py index d2c7000..24e7d4f 100644 --- a/alembic/versions/0121_agent_run_steps.py +++ b/alembic/versions/0121_agent_run_steps.py @@ -14,7 +14,24 @@ branch_labels = None depends_on = None +def _table_exists(conn, table_name: str) -> bool: + """True when the table exists (dual-path convergence, Gate B). + + On a fresh install the automation plugin SQL migration has not run yet + when Alembic reaches this revision — skip instead of failing. The + plugin-side convergence migration creates the same table. + """ + row = conn.execute( + sa.text("SELECT to_regclass(:tname) IS NOT NULL"), + {"tname": f"public.{table_name}"}, + ).scalar() + return bool(row) + + def upgrade() -> None: + conn = op.get_bind() + if not _table_exists(conn, "automation_agent_runs"): + return op.create_table( "automation_agent_run_steps", sa.Column("id", PGUUID(as_uuid=True), primary_key=True), diff --git a/alembic/versions/0122_agent_definition_phase_f_fields.py b/alembic/versions/0122_agent_definition_phase_f_fields.py index b12b54d..57ec8bf 100644 --- a/alembic/versions/0122_agent_definition_phase_f_fields.py +++ b/alembic/versions/0122_agent_definition_phase_f_fields.py @@ -18,7 +18,24 @@ branch_labels = None depends_on = None +def _table_exists(conn, table_name: str) -> bool: + """True when the table exists (dual-path convergence, Gate B). + + On a fresh install the automation plugin SQL migration has not run yet + when Alembic reaches this revision — skip instead of failing. The + plugin-side convergence migration adds the same columns. + """ + row = conn.execute( + sa.text("SELECT to_regclass(:tname) IS NOT NULL"), + {"tname": f"public.{table_name}"}, + ).scalar() + return bool(row) + + def upgrade() -> None: + conn = op.get_bind() + if not _table_exists(conn, "automation_agent_definitions"): + return op.add_column( "automation_agent_definitions", sa.Column("temperature", sa.Float, nullable=False, server_default="0.3"), diff --git a/alembic/versions/0124_unified_task_system.py b/alembic/versions/0124_unified_task_system.py index daf66f1..ace7353 100644 --- a/alembic/versions/0124_unified_task_system.py +++ b/alembic/versions/0124_unified_task_system.py @@ -20,7 +20,27 @@ branch_labels = None depends_on = None +def _table_exists(conn, table_name: str) -> bool: + """True when the table exists (dual-path convergence, Gate B). + + On a fresh install the tasks plugin SQL migration has not run yet when + Alembic reaches this revision — skip instead of failing. The plugin-side + convergence migration adds the same columns/indexes. The legacy-data + backfills below only matter for pre-existing rows and are correctly + empty on a fresh install. + """ + row = conn.execute( + sa.text("SELECT to_regclass(:tname) IS NOT NULL"), + {"tname": f"public.{table_name}"}, + ).scalar() + return bool(row) + + def upgrade() -> None: + conn = op.get_bind() + if not _table_exists(conn, "tasks"): + return + # ── Add new columns to tasks ──────────────────────────────────────────── op.add_column("tasks", sa.Column("assignee_type", sa.String(20), nullable=False, server_default="user")) op.add_column("tasks", sa.Column("assignee_id", PGUUID(as_uuid=True), nullable=True)) diff --git a/alembic/versions/0127_drop_tasks_contact_id_fk.py b/alembic/versions/0127_drop_tasks_contact_id_fk.py index d6fd486..0821edb 100644 --- a/alembic/versions/0127_drop_tasks_contact_id_fk.py +++ b/alembic/versions/0127_drop_tasks_contact_id_fk.py @@ -12,6 +12,7 @@ Revises: 0126 """ from alembic import op +import sqlalchemy as sa revision = "0127" down_revision = "0126" @@ -19,7 +20,19 @@ branch_labels = None depends_on = None +def _table_exists(conn, table_name: str) -> bool: + """True when the table exists (dual-path convergence, Gate B).""" + row = conn.execute( + sa.text("SELECT to_regclass(:tname) IS NOT NULL"), + {"tname": f"public.{table_name}"}, + ).scalar() + return bool(row) + + def upgrade() -> None: + conn = op.get_bind() + if not _table_exists(conn, "tasks"): + return # Drop the FK constraint on tasks.contact_id op.drop_constraint("tasks_contact_id_fkey", "tasks", type_="foreignkey") diff --git a/alembic/versions/0129_rls_for_missing_tables.py b/alembic/versions/0129_rls_for_missing_tables.py index 71c1ab4..f8faeb7 100644 --- a/alembic/versions/0129_rls_for_missing_tables.py +++ b/alembic/versions/0129_rls_for_missing_tables.py @@ -9,6 +9,7 @@ Revises: 0128 """ from alembic import op +import sqlalchemy as sa revision = "0129" down_revision = "0128" @@ -27,8 +28,25 @@ TABLES_NEEDING_RLS = [ ] +def _table_exists(conn, table_name: str) -> bool: + """True when the table exists (dual-path convergence, Gate B). + + Plugin-owned tables may not exist yet on a fresh install when Alembic + reaches this revision — skip them instead of failing. The plugin-side + convergence migrations apply the same RLS policies. + """ + row = conn.execute( + sa.text("SELECT to_regclass(:tname) IS NOT NULL"), + {"tname": f"public.{table_name}"}, + ).scalar() + return bool(row) + + def upgrade() -> None: + conn = op.get_bind() for table in TABLES_NEEDING_RLS: + if not _table_exists(conn, table): + continue op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY;") op.execute( f"CREATE POLICY tenant_isolation ON {table} " @@ -37,6 +55,9 @@ def upgrade() -> None: def downgrade() -> None: + conn = op.get_bind() for table in TABLES_NEEDING_RLS: + if not _table_exists(conn, table): + continue op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table};") op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY;") diff --git a/alembic/versions/0136_fix_rls_tenant_id.py b/alembic/versions/0136_fix_rls_tenant_id.py index 1f40f8b..c3782c4 100644 --- a/alembic/versions/0136_fix_rls_tenant_id.py +++ b/alembic/versions/0136_fix_rls_tenant_id.py @@ -10,6 +10,7 @@ Revises: 0135 Create Date: 2026-08-21 """ from alembic import op +import sqlalchemy as sa revision = "0136" down_revision = "0135" @@ -29,8 +30,25 @@ TABLES_WITH_BAD_RLS = [ ] +def _table_exists(conn, table_name: str) -> bool: + """True when the table exists (dual-path convergence, Gate B). + + Plugin-owned tables may not exist yet on a fresh install when Alembic + reaches this revision — skip them instead of failing. The plugin-side + convergence migrations apply the same RLS policies. + """ + row = conn.execute( + sa.text("SELECT to_regclass(:tname) IS NOT NULL"), + {"tname": f"public.{table_name}"}, + ).scalar() + return bool(row) + + def upgrade() -> None: + conn = op.get_bind() for table in TABLES_WITH_BAD_RLS: + if not _table_exists(conn, table): + continue # Drop old policy with app.tenant_id op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table};") # Create new policy with app.current_tenant_id @@ -41,7 +59,10 @@ def upgrade() -> None: def downgrade() -> None: + conn = op.get_bind() for table in TABLES_WITH_BAD_RLS: + if not _table_exists(conn, table): + continue op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table};") op.execute( f"CREATE POLICY tenant_isolation ON {table} " diff --git a/alembic/versions/0138_tags_tree_structure.py b/alembic/versions/0138_tags_tree_structure.py index c240c2a..70a4f98 100644 --- a/alembic/versions/0138_tags_tree_structure.py +++ b/alembic/versions/0138_tags_tree_structure.py @@ -19,7 +19,25 @@ branch_labels = None depends_on = None +def _table_exists(conn, table_name: str) -> bool: + """True when the table exists (dual-path convergence, Gate B). + + On a fresh install the tags plugin SQL migration has not run yet when + Alembic reaches this revision — skip instead of failing. The plugin-side + convergence migration adds the same columns. + """ + row = conn.execute( + sa.text("SELECT to_regclass(:tname) IS NOT NULL"), + {"tname": f"public.{table_name}"}, + ).scalar() + return bool(row) + + def upgrade() -> None: + conn = op.get_bind() + if not _table_exists(conn, "tags"): + return + # parent_id for tree structure (self-referencing FK) op.add_column("tags", sa.Column("parent_id", PGUUID(as_uuid=True), nullable=True)) op.create_foreign_key( @@ -35,6 +53,9 @@ def upgrade() -> None: def downgrade() -> None: + conn = op.get_bind() + if not _table_exists(conn, "tags"): + return op.drop_column("tags", "icon") op.drop_column("tags", "applicable_to") op.drop_index("ix_tags_parent", table_name="tags") diff --git a/alembic/versions/0139_reports_folder_id.py b/alembic/versions/0139_reports_folder_id.py index 64c22f7..0a14108 100644 --- a/alembic/versions/0139_reports_folder_id.py +++ b/alembic/versions/0139_reports_folder_id.py @@ -18,11 +18,31 @@ branch_labels = None depends_on = None +def _table_exists(conn, table_name: str) -> bool: + """True when the table exists (dual-path convergence, Gate B). + + On a fresh install the report_generator plugin SQL migration has not + run yet when Alembic reaches this revision — skip instead of failing. + The plugin-side convergence migration adds the same column. + """ + row = conn.execute( + sa.text("SELECT to_regclass(:tname) IS NOT NULL"), + {"tname": f"public.{table_name}"}, + ).scalar() + return bool(row) + + def upgrade() -> None: + conn = op.get_bind() + if not _table_exists(conn, "report_templates"): + return op.add_column("report_templates", sa.Column("folder_id", PGUUID(as_uuid=True), nullable=True)) op.create_index("ix_report_templates_folder", "report_templates", ["folder_id"]) def downgrade() -> None: + conn = op.get_bind() + if not _table_exists(conn, "report_templates"): + return op.drop_index("ix_report_templates_folder", table_name="report_templates") op.drop_column("report_templates", "folder_id") diff --git a/alembic/versions/0140_comm_conversation_folders.py b/alembic/versions/0140_comm_conversation_folders.py index 390ef69..b31c53b 100644 --- a/alembic/versions/0140_comm_conversation_folders.py +++ b/alembic/versions/0140_comm_conversation_folders.py @@ -18,11 +18,31 @@ branch_labels = None depends_on = None +def _table_exists(conn, table_name: str) -> bool: + """True when the table exists (dual-path convergence, Gate B). + + On a fresh install the kommunikation plugin SQL migration has not run + yet when Alembic reaches this revision — skip instead of failing. + The plugin-side migration adds the same column idempotently. + """ + row = conn.execute( + sa.text("SELECT to_regclass(:tname) IS NOT NULL"), + {"tname": f"public.{table_name}"}, + ).scalar() + return bool(row) + + def upgrade() -> None: + conn = op.get_bind() + if not _table_exists(conn, "comm_conversations"): + return op.add_column("comm_conversations", sa.Column("folder_id", PGUUID(as_uuid=True), nullable=True)) op.create_index("ix_comm_conversations_folder", "comm_conversations", ["folder_id"]) def downgrade() -> None: + conn = op.get_bind() + if not _table_exists(conn, "comm_conversations"): + return op.drop_index("ix_comm_conversations_folder", table_name="comm_conversations") op.drop_column("comm_conversations", "folder_id") diff --git a/app/plugins/builtins/ai_assistant/migrations/0004_compliance_fields.sql b/app/plugins/builtins/ai_assistant/migrations/0004_compliance_fields.sql new file mode 100644 index 0000000..a671c70 --- /dev/null +++ b/app/plugins/builtins/ai_assistant/migrations/0004_compliance_fields.sql @@ -0,0 +1,10 @@ +-- Dual-path convergence (Gate B): add compliance columns that Alembic +-- migration 0119 adds on the core path. Idempotent so both install paths +-- converge to the identical schema. +ALTER TABLE ai_providers ADD COLUMN IF NOT EXISTS region VARCHAR(20) NOT NULL DEFAULT 'unknown'; +ALTER TABLE ai_providers ADD COLUMN IF NOT EXISTS hosting_type VARCHAR(30) NOT NULL DEFAULT 'cloud'; +ALTER TABLE ai_providers ADD COLUMN IF NOT EXISTS dpa_status VARCHAR(20) NOT NULL DEFAULT 'none'; +ALTER TABLE ai_providers ADD COLUMN IF NOT EXISTS retention_policy TEXT NOT NULL DEFAULT ''; +ALTER TABLE ai_providers ADD COLUMN IF NOT EXISTS training_on_customer_data BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE ai_providers ADD COLUMN IF NOT EXISTS transfer_notice TEXT NOT NULL DEFAULT ''; +ALTER TABLE ai_providers ADD COLUMN IF NOT EXISTS allowed_data_classes JSONB NOT NULL DEFAULT '[]'::jsonb; diff --git a/app/plugins/builtins/ai_assistant/plugin.py b/app/plugins/builtins/ai_assistant/plugin.py index dfeebce..f6290d3 100644 --- a/app/plugins/builtins/ai_assistant/plugin.py +++ b/app/plugins/builtins/ai_assistant/plugin.py @@ -42,7 +42,7 @@ class AIAssistantPlugin(BasePlugin): ), ], events=[], - migrations=["0001_initial.sql", "0002_folders_attachments.sql"], + migrations=["0001_initial.sql", "0002_folders_attachments.sql", "0003_sort_order.sql", "0004_compliance_fields.sql"], permissions=[ "ai:read", "ai:write", diff --git a/app/plugins/builtins/automation/migrations/0004_run_steps_phase_f.sql b/app/plugins/builtins/automation/migrations/0004_run_steps_phase_f.sql new file mode 100644 index 0000000..ef95d7c --- /dev/null +++ b/app/plugins/builtins/automation/migrations/0004_run_steps_phase_f.sql @@ -0,0 +1,31 @@ +-- Dual-path convergence (Gate B): create the ReAct step-tracking table +-- that Alembic migration 0121 creates on the core path, add the Phase-F +-- columns from 0122, and apply the RLS policy from 0129/0136. Idempotent +-- so both install paths converge to the identical schema. +CREATE TABLE IF NOT EXISTS automation_agent_run_steps ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + agent_run_id UUID NOT NULL REFERENCES automation_agent_runs(id) ON DELETE CASCADE, + step_number INTEGER NOT NULL, + thought TEXT, + action VARCHAR(255), + action_input JSONB, + observation TEXT, + cost_usd FLOAT NOT NULL DEFAULT 0.0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS ix_agent_run_steps_run ON automation_agent_run_steps(tenant_id, agent_run_id); + +ALTER TABLE automation_agent_definitions ADD COLUMN IF NOT EXISTS temperature FLOAT NOT NULL DEFAULT 0.3; +ALTER TABLE automation_agent_definitions ADD COLUMN IF NOT EXISTS max_tokens INTEGER NOT NULL DEFAULT 1000; +ALTER TABLE automation_agent_definitions ADD COLUMN IF NOT EXISTS max_steps INTEGER NOT NULL DEFAULT 20; +ALTER TABLE automation_agent_definitions ADD COLUMN IF NOT EXISTS trace_mode VARCHAR(20) NOT NULL DEFAULT 'standard'; +ALTER TABLE automation_agent_definitions ADD COLUMN IF NOT EXISTS skill_ids JSONB NOT NULL DEFAULT '[]'::jsonb; +ALTER TABLE automation_agent_definitions ADD COLUMN IF NOT EXISTS trigger_config JSONB NOT NULL DEFAULT '{}'::jsonb; +ALTER TABLE automation_agent_definitions ADD COLUMN IF NOT EXISTS ai_use_case_metadata JSONB NOT NULL DEFAULT '{}'::jsonb; + +-- RLS matching migrations 0129 + 0136 (current_tenant_id variant) +ALTER TABLE automation_agent_run_steps ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation ON automation_agent_run_steps; +CREATE POLICY tenant_isolation ON automation_agent_run_steps + USING (tenant_id::text = current_setting('app.current_tenant_id', true)); diff --git a/app/plugins/builtins/automation/plugin.py b/app/plugins/builtins/automation/plugin.py index 6fe45b3..523da60 100644 --- a/app/plugins/builtins/automation/plugin.py +++ b/app/plugins/builtins/automation/plugin.py @@ -62,7 +62,7 @@ class AutomationPlugin(BasePlugin): "mail.received", "workflow.timeout", ], - migrations=["0001_initial.sql", "0002_agent_subtasks.sql", "0003_skill_definitions.sql"], + migrations=["0001_initial.sql", "0002_agent_subtasks.sql", "0003_skill_definitions.sql", "0004_run_steps_phase_f.sql"], permissions=[ "automation:read", "automation:write", diff --git a/app/plugins/builtins/knowledge/plugin.py b/app/plugins/builtins/knowledge/plugin.py index ca24136..02d2195 100644 --- a/app/plugins/builtins/knowledge/plugin.py +++ b/app/plugins/builtins/knowledge/plugin.py @@ -25,7 +25,7 @@ class KnowledgePlugin(BasePlugin): """Register event-driven extraction hooks on activation.""" await super().on_activate(db, service_container, event_bus) try: - from app.core.hooks import register_action + from app.core.hooks import get_hook_registry from app.plugins.builtins.knowledge.services import extract_knowledge async def on_wiki_create(*args, **kwargs): article_id = kwargs.get("article_id") or kwargs.get("entity_id") @@ -41,7 +41,9 @@ class KnowledgePlugin(BasePlugin): source_type="wiki_article", source_id=uuid.UUID(str(article_id)), source_title=title, source_text=content, ) - register_action("wiki.article.created", on_wiki_create, priority=20, owner_tag="knowledge") + get_hook_registry().register_action( + "wiki.article.created", on_wiki_create, priority=20, owner_tag="knowledge" + ) # H-DATA-LIFE: Re-extract when wiki article is updated async def on_wiki_update(*args, **kwargs): article_id = kwargs.get("article_id") or kwargs.get("entity_id") @@ -57,7 +59,9 @@ class KnowledgePlugin(BasePlugin): source_type="wiki_article", source_id=uuid.UUID(str(article_id)), source_title=title, source_text=content, ) - register_action("wiki.article.updated", on_wiki_update, priority=20, owner_tag="knowledge") + get_hook_registry().register_action( + "wiki.article.updated", on_wiki_update, priority=20, owner_tag="knowledge" + ) logger.info("Registered knowledge extraction hooks") except Exception: logger.exception("Failed to register knowledge hooks") diff --git a/app/plugins/builtins/kommunikation/migrations/0002_system_channel_folders.sql b/app/plugins/builtins/kommunikation/migrations/0002_system_channel_folders.sql new file mode 100644 index 0000000..966af0c --- /dev/null +++ b/app/plugins/builtins/kommunikation/migrations/0002_system_channel_folders.sql @@ -0,0 +1,7 @@ +-- Dual-path convergence (Gate B): add columns that Alembic migrations +-- 0120 (is_system) and 0140 (folder_id) add on the core path. Idempotent +-- so both install paths converge to the identical schema. +ALTER TABLE comm_conversations ADD COLUMN IF NOT EXISTS is_system BOOLEAN NOT NULL DEFAULT FALSE; +CREATE INDEX IF NOT EXISTS ix_comm_conversations_tenant_system ON comm_conversations(tenant_id, is_system); +ALTER TABLE comm_conversations ADD COLUMN IF NOT EXISTS folder_id UUID; +CREATE INDEX IF NOT EXISTS ix_comm_conversations_folder ON comm_conversations(folder_id); diff --git a/app/plugins/builtins/kommunikation/plugin.py b/app/plugins/builtins/kommunikation/plugin.py index 2796c23..ab0a6b9 100644 --- a/app/plugins/builtins/kommunikation/plugin.py +++ b/app/plugins/builtins/kommunikation/plugin.py @@ -39,7 +39,7 @@ class KommunikationPlugin(BasePlugin): "participant.left", "reaction.added", ], - migrations=["0001_initial.sql"], + migrations=["0001_initial.sql", "0002_system_channel_folders.sql"], permissions=[ "comm:read", "comm:write", diff --git a/app/plugins/builtins/report_generator/migrations/0002_reports_folder_id.sql b/app/plugins/builtins/report_generator/migrations/0002_reports_folder_id.sql new file mode 100644 index 0000000..4e76663 --- /dev/null +++ b/app/plugins/builtins/report_generator/migrations/0002_reports_folder_id.sql @@ -0,0 +1,5 @@ +-- Dual-path convergence (Gate B): add the folder_id column that Alembic +-- migration 0139 adds on the core path. Idempotent so both install paths +-- converge to the identical schema. +ALTER TABLE report_templates ADD COLUMN IF NOT EXISTS folder_id UUID; +CREATE INDEX IF NOT EXISTS ix_report_templates_folder ON report_templates(folder_id); diff --git a/app/plugins/builtins/report_generator/plugin.py b/app/plugins/builtins/report_generator/plugin.py index 5317e6c..baf7b90 100644 --- a/app/plugins/builtins/report_generator/plugin.py +++ b/app/plugins/builtins/report_generator/plugin.py @@ -24,7 +24,7 @@ class ReportGeneratorPlugin(BasePlugin): ), ], events=["report.requested", "report.generated"], - migrations=["0001_initial.sql"], + migrations=["0001_initial.sql", "0002_reports_folder_id.sql"], permissions=["reports:read", "reports:generate", "reports:manage_templates"], menu_items=[ FrontendMenuItem(label_key='nav.reports', label='Berichte', path='/reports', icon='BarChart3', order=70), diff --git a/app/plugins/builtins/tags/migrations/0003_tree_structure.sql b/app/plugins/builtins/tags/migrations/0003_tree_structure.sql new file mode 100644 index 0000000..15695f1 --- /dev/null +++ b/app/plugins/builtins/tags/migrations/0003_tree_structure.sql @@ -0,0 +1,18 @@ +-- Dual-path convergence (Gate B): apply the tag tree structure that +-- Alembic migration 0138 adds on the core path. Idempotent so both install +-- paths converge to the identical schema. +ALTER TABLE tags ADD COLUMN IF NOT EXISTS parent_id UUID; +CREATE INDEX IF NOT EXISTS ix_tags_parent ON tags(parent_id); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_tags_parent_id' + ) THEN + ALTER TABLE tags ADD CONSTRAINT fk_tags_parent_id + FOREIGN KEY (parent_id) REFERENCES tags(id) ON DELETE SET NULL; + END IF; +END $$; + +ALTER TABLE tags ADD COLUMN IF NOT EXISTS applicable_to JSONB; +ALTER TABLE tags ADD COLUMN IF NOT EXISTS icon VARCHAR(50); diff --git a/app/plugins/builtins/tags/plugin.py b/app/plugins/builtins/tags/plugin.py index 174e0ae..e9cfbc5 100644 --- a/app/plugins/builtins/tags/plugin.py +++ b/app/plugins/builtins/tags/plugin.py @@ -23,7 +23,7 @@ class TagsPlugin(BasePlugin): ), ], events=[], - migrations=["0001_initial.sql", "0002_add_deleted_at.sql"], + migrations=["0001_initial.sql", "0002_add_deleted_at.sql", "0003_tree_structure.sql"], permissions=[ "tags:read", "tags:write", diff --git a/app/plugins/builtins/tasks/migrations/0002_unified_task_system.sql b/app/plugins/builtins/tasks/migrations/0002_unified_task_system.sql new file mode 100644 index 0000000..62c7494 --- /dev/null +++ b/app/plugins/builtins/tasks/migrations/0002_unified_task_system.sql @@ -0,0 +1,31 @@ +-- Dual-path convergence (Gate B): apply the unified task system that +-- Alembic migrations 0124 (columns/indexes/FK) and 0127 (drop legacy FK) +-- create on the core path. Idempotent so both install paths converge to +-- the identical schema. The end state has NO foreign key on contact_id. +ALTER TABLE tasks ADD COLUMN IF NOT EXISTS assignee_type VARCHAR(20) NOT NULL DEFAULT 'user'; +ALTER TABLE tasks ADD COLUMN IF NOT EXISTS assignee_id UUID; +ALTER TABLE tasks ADD COLUMN IF NOT EXISTS entity_type VARCHAR(80); +ALTER TABLE tasks ADD COLUMN IF NOT EXISTS entity_id UUID; +ALTER TABLE tasks ADD COLUMN IF NOT EXISTS creator_type VARCHAR(20) NOT NULL DEFAULT 'user'; +ALTER TABLE tasks ADD COLUMN IF NOT EXISTS creator_id UUID; +ALTER TABLE tasks ADD COLUMN IF NOT EXISTS parent_task_id UUID; +ALTER TABLE tasks ADD COLUMN IF NOT EXISTS depends_on JSONB NOT NULL DEFAULT '[]'::jsonb; +ALTER TABLE tasks ADD COLUMN IF NOT EXISTS task_type VARCHAR(30) NOT NULL DEFAULT 'todo'; +ALTER TABLE tasks ADD COLUMN IF NOT EXISTS success_criteria JSONB; +ALTER TABLE tasks ADD COLUMN IF NOT EXISTS target_date TIMESTAMPTZ; +ALTER TABLE tasks ADD COLUMN IF NOT EXISTS progress INTEGER NOT NULL DEFAULT 0; + +CREATE INDEX IF NOT EXISTS ix_tasks_tenant_entity ON tasks(tenant_id, entity_type, entity_id); +CREATE INDEX IF NOT EXISTS ix_tasks_tenant_assignee ON tasks(tenant_id, assignee_type, assignee_id); +CREATE INDEX IF NOT EXISTS ix_tasks_tenant_parent ON tasks(tenant_id, parent_task_id); +CREATE INDEX IF NOT EXISTS ix_tasks_tenant_type ON tasks(tenant_id, task_type); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_tasks_parent_task_id' + ) THEN + ALTER TABLE tasks ADD CONSTRAINT fk_tasks_parent_task_id + FOREIGN KEY (parent_task_id) REFERENCES tasks(id) ON DELETE CASCADE; + END IF; +END $$; diff --git a/app/plugins/builtins/tasks/plugin.py b/app/plugins/builtins/tasks/plugin.py index 6ff81ee..260a6e3 100644 --- a/app/plugins/builtins/tasks/plugin.py +++ b/app/plugins/builtins/tasks/plugin.py @@ -29,7 +29,7 @@ class TasksPlugin(BasePlugin): ), ], events=[], - migrations=["0001_initial.sql"], + migrations=["0001_initial.sql", "0002_unified_task_system.sql"], permissions=[ "tasks:read", "tasks:write",