From a2e90679752946fd6cc6f8e9c9140277d9587ce5 Mon Sep 17 00:00:00 2001 From: Swetha S Date: Tue, 19 May 2026 15:32:42 +0530 Subject: [PATCH] feat: add default call execution config fallback support --- .../agents/breeze_buddy/managers/calls.py | 17 +++++++ .../routers/breeze_buddy/leads/handlers.py | 21 ++++++++ .../breeze_buddy/call_execution_config.py | 10 ++-- ...able_template_in_call_execution_config.sql | 49 ++++++++++++++++++ .../breeze_buddy/call_execution_config.py | 50 ++++++++++++++----- app/database/queries/breeze_buddy/template.py | 2 +- app/schemas/breeze_buddy/core.py | 6 +-- 7 files changed, 134 insertions(+), 21 deletions(-) create mode 100644 app/database/migrations/029_nullable_template_in_call_execution_config.sql diff --git a/app/ai/voice/agents/breeze_buddy/managers/calls.py b/app/ai/voice/agents/breeze_buddy/managers/calls.py index a2de22a6b..057e4d3ec 100644 --- a/app/ai/voice/agents/breeze_buddy/managers/calls.py +++ b/app/ai/voice/agents/breeze_buddy/managers/calls.py @@ -105,6 +105,23 @@ async def _get_lead_config(lead: LeadCallTracker) -> Optional[CallExecutionConfi ), None, ) + if not config: + # Step 3: fall back to the default config (template IS NULL) + # Prefer merchant-specific default, then reseller-wide default + if lead.merchant_id: + config = next( + ( + c + for c in configs + if c.template is None and c.merchant_id == lead.merchant_id + ), + None, + ) + if not config: + config = next( + (c for c in configs if c.template is None and c.merchant_id is None), + None, + ) if not config: logger.warning( f"No call execution config found for template: {lead.template} (template_id={lead.template_id})" diff --git a/app/api/routers/breeze_buddy/leads/handlers.py b/app/api/routers/breeze_buddy/leads/handlers.py index 78de58706..d69b401f0 100644 --- a/app/api/routers/breeze_buddy/leads/handlers.py +++ b/app/api/routers/breeze_buddy/leads/handlers.py @@ -251,6 +251,27 @@ async def push_lead_handler(req: PushLeadRequest, current_user: UserInfo) -> Dic None, ) + if not config: + # Step 3: fall back to the default config (template IS NULL) + # Prefer merchant-specific default, then reseller-wide default + if req.merchant_id: + config = next( + ( + c + for c in call_execution_configs + if c.template is None and c.merchant_id == req.merchant_id + ), + None, + ) + if not config: + config = next( + ( + c + for c in call_execution_configs + if c.template is None and c.merchant_id is None + ), + None, + ) if not config: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, diff --git a/app/database/accessor/breeze_buddy/call_execution_config.py b/app/database/accessor/breeze_buddy/call_execution_config.py index 555d0708f..7c48dc2d3 100644 --- a/app/database/accessor/breeze_buddy/call_execution_config.py +++ b/app/database/accessor/breeze_buddy/call_execution_config.py @@ -52,7 +52,7 @@ async def create_call_execution_config( max_retry: int, calling_provider: CallProvider, reseller_id: str, - template: str, + template: Optional[str], merchant_id: Optional[str], enable_international_call: bool, enable_calling: bool = True, @@ -72,7 +72,9 @@ async def create_call_execution_config( pre_checks: Optional[List[Any]] = None, telephony_config: Optional[TelephonyConfig] = None, ) -> Optional[CallExecutionConfig]: - """Create a new call execution config record (upsert based on merchant_id + template).""" + """Create a new call execution config record (upsert based on partial unique indexes). + When template is None the row acts as the DEFAULT config for the merchant/reseller. + """ logger.info(f"Creating call execution config for reseller ID: {reseller_id}") try: @@ -144,7 +146,7 @@ async def get_call_execution_config_by_merchant_id( ) return decoded_result - if merchant_id: + if merchant_id is not None: # If no config is found for the specific merchant_id, try with NULL logger.info( f"No config found for merchant_id {merchant_id}, trying generic config." @@ -193,7 +195,7 @@ async def get_all_call_execution_configs() -> List[CallExecutionConfig]: async def update_call_execution_config( reseller_id: str, - template: str, + template: Optional[str] = None, merchant_id: Optional[str] = None, initial_offset: Optional[int] = None, retry_offset: Optional[int] = None, diff --git a/app/database/migrations/029_nullable_template_in_call_execution_config.sql b/app/database/migrations/029_nullable_template_in_call_execution_config.sql new file mode 100644 index 000000000..cc47a7e77 --- /dev/null +++ b/app/database/migrations/029_nullable_template_in_call_execution_config.sql @@ -0,0 +1,49 @@ +-- Migration 029: Allow NULL template in call_execution_config for default configs +-- +-- Problem: Every template required its own call_execution_config row, even when +-- all timing/settings were identical. +-- +-- Solution: A row with template IS NULL acts as the DEFAULT config for a +-- reseller/merchant. The resolution chain is: +-- 1. Exact template_id match +-- 2. Exact template name match +-- 3. Default config (template IS NULL) <-- NEW +-- +-- Changes: +-- 1. Make the `template` column nullable +-- 2. Drop the old non-partial unique constraint (doesn't handle NULLs correctly) +-- 3. Replace with partial unique indexes that cover all four cases: +-- a) template IS NOT NULL, merchant IS NOT NULL → unique on (merchant_id, template) +-- b) template IS NOT NULL, merchant IS NULL → unique on (reseller_id, template) +-- c) template IS NULL, merchant IS NOT NULL → unique on (reseller_id, merchant_id) [DEFAULT per merchant] +-- d) template IS NULL, merchant IS NULL → unique on (reseller_id) [DEFAULT for reseller] + +-- Step 1: Make template nullable +ALTER TABLE call_execution_config + ALTER COLUMN "template" DROP NOT NULL; + +-- Step 2: Drop the old non-null-aware unique constraint +ALTER TABLE call_execution_config + DROP CONSTRAINT IF EXISTS uq_call_execution_config_merchant_template; + +-- Step 3a: Per-template, per-merchant uniqueness (matches old behaviour) +CREATE UNIQUE INDEX IF NOT EXISTS uq_call_execution_config_template_merchant + ON call_execution_config (merchant_id, template) + WHERE template IS NOT NULL AND merchant_id IS NOT NULL; + +-- Step 3b: Per-template, no merchant (reseller-level) uniqueness +-- (replaces the old uq_call_execution_config_reseller_template_null_merchant) +DROP INDEX IF EXISTS uq_call_execution_config_reseller_template_null_merchant; +CREATE UNIQUE INDEX IF NOT EXISTS uq_call_execution_config_template_null_merchant + ON call_execution_config (reseller_id, template) + WHERE template IS NOT NULL AND merchant_id IS NULL; + +-- Step 3c: Default config per merchant (template IS NULL, merchant IS NOT NULL) +CREATE UNIQUE INDEX IF NOT EXISTS uq_call_execution_config_default_with_merchant + ON call_execution_config (reseller_id, merchant_id) + WHERE template IS NULL AND merchant_id IS NOT NULL; + +-- Step 3d: Default config for reseller (template IS NULL, merchant IS NULL) +CREATE UNIQUE INDEX IF NOT EXISTS uq_call_execution_config_default_no_merchant + ON call_execution_config (reseller_id) + WHERE template IS NULL AND merchant_id IS NULL; diff --git a/app/database/queries/breeze_buddy/call_execution_config.py b/app/database/queries/breeze_buddy/call_execution_config.py index 25b71a887..bbe6dbb27 100644 --- a/app/database/queries/breeze_buddy/call_execution_config.py +++ b/app/database/queries/breeze_buddy/call_execution_config.py @@ -11,6 +11,26 @@ CALL_EXECUTION_CONFIG_TABLE = "call_execution_config" +def _conflict_target(template: Optional[str], merchant_id: Optional[str]) -> str: + """ + Return the ON CONFLICT partial-index target for an upsert into call_execution_config. + + The migration created four partial unique indexes that cover all combinations of + nullable template and nullable merchant_id — this helper picks the right one. + """ + if template is None: + return ( + "(reseller_id, merchant_id) WHERE template IS NULL AND merchant_id IS NOT NULL" + if merchant_id is not None + else "(reseller_id) WHERE template IS NULL AND merchant_id IS NULL" + ) + return ( + "(merchant_id, template) WHERE template IS NOT NULL AND merchant_id IS NOT NULL" + if merchant_id is not None + else "(reseller_id, template) WHERE template IS NOT NULL AND merchant_id IS NULL" + ) + + # Call execution config queries def insert_call_execution_config_query( id: str, @@ -21,7 +41,7 @@ def insert_call_execution_config_query( max_retry: int, calling_provider: CallProvider, reseller_id: str, - template: str, + template: Optional[str], merchant_id: Optional[str], enable_international_call: bool, enable_calling: bool = True, @@ -43,11 +63,11 @@ def insert_call_execution_config_query( ) -> Tuple[str, List[Any]]: """ Generate query to insert call execution config record. - Uses ON CONFLICT to upsert based on (merchant_id, template) to prevent duplicates. + Uses ON CONFLICT to upsert based on unique constraints to prevent duplicates. Args: template_id: UUID of the template (preferred, for referential integrity) - template: Name of the template (kept for backward compatibility) + template: Name of the template (kept for backwards compatibility) pre_checks: JSON string of pre-check configurations telephony_config: JSON string of telephony provider overrides """ @@ -85,7 +105,7 @@ def insert_call_execution_config_query( "updated_at" ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29) - ON CONFLICT (merchant_id, template) DO UPDATE SET + ON CONFLICT {_conflict_target(template, merchant_id)} DO UPDATE SET initial_offset = EXCLUDED.initial_offset, retry_offset = EXCLUDED.retry_offset, call_start_time = EXCLUDED.call_start_time, @@ -155,7 +175,7 @@ def get_call_execution_config_by_merchant_id_query( """ Generate query to get call execution config by reseller ID and merchant identifier. """ - if merchant_id: + if merchant_id is not None: text = f""" SELECT * FROM "{CALL_EXECUTION_CONFIG_TABLE}" @@ -208,7 +228,7 @@ def delete_call_execution_config_query(config_id: str) -> Tuple[str, List[Any]]: def update_call_execution_config_query( reseller_id: str, - template: str, + template: Optional[str] = None, merchant_id: Optional[str] = None, initial_offset: Optional[int] = None, retry_offset: Optional[int] = None, @@ -315,16 +335,20 @@ def _add(col: str, val: Any) -> None: reseller_id_param = param_count param_count += 1 - values.append(template) - template_param = param_count - param_count += 1 + if template is not None: + values.append(template) + template_param = param_count + param_count += 1 + template_clause = f'"template" = ${template_param}' + else: + template_clause = '"template" IS NULL' - if merchant_id: + if merchant_id is not None: values.append(merchant_id) merchant_identifier_param = param_count - where_clause = f'reseller_id = ${reseller_id_param} AND "template" = ${template_param} AND merchant_id = ${merchant_identifier_param}' + where_clause = f"reseller_id = ${reseller_id_param} AND {template_clause} AND merchant_id = ${merchant_identifier_param}" else: - where_clause = f'reseller_id = ${reseller_id_param} AND "template" = ${template_param} AND merchant_id IS NULL' + where_clause = f"reseller_id = ${reseller_id_param} AND {template_clause} AND merchant_id IS NULL" text = f""" UPDATE "{CALL_EXECUTION_CONFIG_TABLE}" @@ -356,7 +380,7 @@ def calling_activation_for_merchant_query( SET "enable_calling" = $1, "updated_at" = $2 RETURNING *; """ - elif merchant_id: + elif merchant_id is not None: # Update specific merchant (we always have reseller_id for the merchant) text = f""" UPDATE "{CALL_EXECUTION_CONFIG_TABLE}" diff --git a/app/database/queries/breeze_buddy/template.py b/app/database/queries/breeze_buddy/template.py index f9c8d06ab..3b740e236 100644 --- a/app/database/queries/breeze_buddy/template.py +++ b/app/database/queries/breeze_buddy/template.py @@ -17,7 +17,7 @@ def get_template_by_merchant_query( conditions = ["reseller_id = $1"] values = [reseller_id] - if merchant_id: + if merchant_id is not None: conditions.append(f"merchant_id = ${len(values) + 1}") values.append(merchant_id) else: diff --git a/app/schemas/breeze_buddy/core.py b/app/schemas/breeze_buddy/core.py index 4fd4e5cba..dbb8cb3ff 100644 --- a/app/schemas/breeze_buddy/core.py +++ b/app/schemas/breeze_buddy/core.py @@ -216,7 +216,7 @@ class CreateCallExecutionConfigRequest(BaseModel): max_retry: int calling_provider: CallProvider reseller_id: str - template: str + template: Optional[str] = None merchant_id: Optional[str] = None enable_international_call: bool = True enable_calling: Optional[bool] = True @@ -262,7 +262,7 @@ class UpdateCallExecutionConfigRequest(BaseModel): """Request to update call execution configuration""" reseller_id: str - template: str + template: Optional[str] = None merchant_id: Optional[str] = None initial_offset: Optional[int] = None retry_offset: Optional[int] = None @@ -323,7 +323,7 @@ class CallExecutionConfig(BaseModel): max_retry: int calling_provider: CallProvider reseller_id: str - template: str + template: Optional[str] = None template_id: Optional[str] = None merchant_id: Optional[str] = None enable_international_call: bool = True