Skip to content

Api reference

components.support.public.actions

csat_actions

Public actions for escalated conversation CSAT mutations.

Used by country handlers (e.g. FR) to write SupportCSAT records without directly accessing internal models (ContactRequest, SupportCSAT).

answer_escalated_csat

answer_escalated_csat(
    intercom_conversation_id,
    intercom_workspace_id,
    rating,
    comment=None,
)

Record a member's CSAT response for an escalated Intercom conversation.

Source code in components/support/public/actions/csat_actions.py
def answer_escalated_csat(
    intercom_conversation_id: str,
    intercom_workspace_id: str,
    rating: int,
    comment: str | None = None,
) -> None:
    """Record a member's CSAT response for an escalated Intercom conversation."""
    from datetime import UTC, datetime

    from components.support.internal.business_logic.queries.contact_request_queries import (
        get_or_none_contact_request_by_intercom_conversation,
    )
    from components.support.internal.business_logic.queries.support_csat_queries import (
        get_support_csat_by_contact_request_id,
    )
    from components.support.public.enums.support_csat_status import (
        SupportCSATStatus,
    )
    from shared.helpers.db import current_session

    contact_request = get_or_none_contact_request_by_intercom_conversation(
        intercom_conversation_id=intercom_conversation_id,
        intercom_workspace_id=intercom_workspace_id,
    )
    if not contact_request:
        current_logger.warning(
            "answer_escalated_csat: no ContactRequest found",
            intercom_conversation_id=intercom_conversation_id,
        )
        return

    support_csat = get_support_csat_by_contact_request_id(contact_request.id)
    if not support_csat:
        current_logger.warning(
            "answer_escalated_csat: no SupportCSAT found",
            intercom_conversation_id=intercom_conversation_id,
        )
        return

    support_csat.rating = rating
    support_csat.comment = comment
    support_csat.status = SupportCSATStatus.answered
    support_csat.member_reply_at = datetime.now(UTC)
    current_session.add(support_csat)
    current_session.commit()

    _send_escalated_csat_to_intercom(
        intercom_conversation_id=intercom_conversation_id,
        intercom_workspace_id=intercom_workspace_id,
        rating=rating,
        comment=comment,
    )

dismiss_escalated_csat

dismiss_escalated_csat(
    intercom_conversation_id, intercom_workspace_id
)

Dismiss a CSAT survey for an escalated Intercom conversation.

Source code in components/support/public/actions/csat_actions.py
def dismiss_escalated_csat(
    intercom_conversation_id: str,
    intercom_workspace_id: str,
) -> None:
    """Dismiss a CSAT survey for an escalated Intercom conversation."""
    from components.support.internal.business_logic.queries.contact_request_queries import (
        get_or_none_contact_request_by_intercom_conversation,
    )
    from components.support.internal.business_logic.queries.support_csat_queries import (
        get_support_csat_by_contact_request_id,
    )
    from components.support.public.enums.support_csat_status import (
        SupportCSATStatus,
    )
    from shared.helpers.db import current_session

    contact_request = get_or_none_contact_request_by_intercom_conversation(
        intercom_conversation_id=intercom_conversation_id,
        intercom_workspace_id=intercom_workspace_id,
    )
    if not contact_request:
        current_logger.warning(
            "dismiss_escalated_csat: no ContactRequest found",
            intercom_conversation_id=intercom_conversation_id,
        )
        return

    support_csat = get_support_csat_by_contact_request_id(contact_request.id)
    if not support_csat:
        current_logger.warning(
            "dismiss_escalated_csat: no SupportCSAT found",
            intercom_conversation_id=intercom_conversation_id,
        )
        return

    support_csat.status = SupportCSATStatus.dismissed
    current_session.add(support_csat)
    current_session.commit()

ensure_escalated_csat_on_close

ensure_escalated_csat_on_close(
    intercom_conversation_id, intercom_workspace_id
)

Create or re-trigger SupportCSAT for an escalated conversation on close.

On first close: creates a new CSAT if eligible. On re-close (after reopen): resets existing CSAT to sent status, updates survey_sent_at and support_agent_id so the member gets a fresh survey while previous rating/comment are preserved until overwritten.

Source code in components/support/public/actions/csat_actions.py
def ensure_escalated_csat_on_close(
    intercom_conversation_id: str,
    intercom_workspace_id: str,
) -> None:
    """Create or re-trigger SupportCSAT for an escalated conversation on close.

    On first close: creates a new CSAT if eligible.
    On re-close (after reopen): resets existing CSAT to ``sent`` status,
    updates ``survey_sent_at`` and ``support_agent_id`` so the member gets
    a fresh survey while previous rating/comment are preserved until overwritten.
    """
    from datetime import UTC, datetime

    from components.support.internal.business_logic.actions.support_csat_actions import (
        check_csat_eligibility_for_sync_conversation,
        get_or_create_support_csat,
    )
    from components.support.internal.business_logic.queries.contact_request_queries import (
        get_or_none_contact_request_by_intercom_conversation,
    )
    from components.support.internal.business_logic.queries.support_csat_queries import (
        get_support_csat_by_contact_request_id,
    )
    from components.support.public.enums.support_csat_status import (
        SupportCSATStatus,
    )
    from shared.helpers.db import current_session
    from shared.helpers.intercom import convert_to_intercom_conversation
    from shared.services.intercom.intercom_care_client import (
        get_care_intercom_client,
    )

    contact_request = get_or_none_contact_request_by_intercom_conversation(
        intercom_conversation_id=intercom_conversation_id,
        intercom_workspace_id=intercom_workspace_id,
    )
    if not contact_request:
        current_logger.warning(
            "ensure_escalated_csat_on_close: no ContactRequest found",
            intercom_conversation_id=intercom_conversation_id,
        )
        return

    intercom_client = get_care_intercom_client(contact_request.app_id)
    conversation_data = intercom_client.get_conversation_details(
        conversation_id=intercom_conversation_id
    )
    intercom_conversation = convert_to_intercom_conversation(conversation_data)

    # Resolve support agent from Intercom team assignee
    agent_id = _resolve_support_agent_id(
        str(intercom_conversation.team_assignee_id)
        if intercom_conversation.team_assignee_id is not None
        else None
    )

    existing_csat = get_support_csat_by_contact_request_id(contact_request.id)

    if existing_csat:
        # Re-close after reopen: re-trigger CSAT survey
        existing_csat.status = SupportCSATStatus.sent
        existing_csat.survey_sent_at = datetime.now(UTC)

        if agent_id:
            existing_csat.support_agent_id = agent_id

        current_session.add(existing_csat)
        current_session.commit()

        current_logger.info(
            "ensure_escalated_csat_on_close: re-triggered existing CSAT",
            contact_request_id=str(contact_request.id),
        )
        return

    # First close: fetch Intercom data and check eligibility.
    # Use convert_to_intercom_conversation (not from_dict) because the REST API v2
    # returns "contacts" instead of "user

    eligibility = check_csat_eligibility_for_sync_conversation(
        intercom_conversation=intercom_conversation,
        contact_request_id=contact_request.id,
        intercom_workspace_id=intercom_workspace_id,
    )
    if not eligibility.is_eligible:
        current_logger.info(
            "ensure_escalated_csat_on_close: not eligible",
            reason=eligibility.reason,
            contact_request_id=str(contact_request.id),
        )
        return

    get_or_create_support_csat(
        contact_request_id=contact_request.id,
        support_agent_id=agent_id,
    )

merge_users

Public merge helper for the support component.

Reassigns ContactRequest ownership during user deduplication so that SupportCSAT, PhoneSupportCSAT, and all entities linked via ContactRequest follow the merged user.

merge_users_in_support_component

merge_users_in_support_component(
    source_user_id, target_user_id, app_name, commit=False
)

Merge all support data from source user to target user.

Updates ContactRequest.app_user_id for every contact request that belongs to source_user_id within the given app_name so it points to target_user_id.

Parameters:

Name Type Description Default
source_user_id str

User ID to merge from.

required
target_user_id str

User ID to merge into.

required
app_name AppName

App identifier to scope the update.

required
commit bool

Whether to commit the transaction (default: False).

False

Returns:

Type Description
list[str]

List of log messages describing actions taken.

Source code in components/support/public/actions/merge_users.py
def merge_users_in_support_component(
    source_user_id: str,
    target_user_id: str,
    app_name: AppName,
    commit: bool = False,
) -> list[str]:
    """Merge all support data from source user to target user.

    Updates ``ContactRequest.app_user_id`` for every contact request that
    belongs to *source_user_id* within the given *app_name* so it points
    to *target_user_id*.

    Args:
        source_user_id: User ID to merge from.
        target_user_id: User ID to merge into.
        app_name: App identifier to scope the update.
        commit: Whether to commit the transaction (default: False).

    Returns:
        List of log messages describing actions taken.
    """
    from sqlalchemy import update

    from components.support.internal.models.contact_request import ContactRequest
    from shared.helpers.db import current_session

    affected_ids = (
        current_session.execute(
            update(ContactRequest)
            .where(
                ContactRequest.app_id == app_name.value,
                ContactRequest.app_user_id == source_user_id,
            )
            .values(app_user_id=target_user_id)
            .returning(ContactRequest.id)
        )
        .scalars()
        .all()
    )

    if commit:
        current_session.commit()

    return [
        f"Update ContactRequest {cr_id} app_user_id "
        f"from {source_user_id} to {target_user_id}."
        for cr_id in affected_ids
    ]

components.support.public.blueprint

support_blueprint module-attribute

support_blueprint = create_blueprint(
    name="support",
    import_name=__name__,
    template_folder=join(
        dirname(__file__), "..", "templates"
    ),
    cli_group="support",
)

components.support.public.commands

backfill_fr_contact_requests_from_turing

backfill_fr_contact_requests_from_turing

backfill_fr_contact_requests_from_turing(
    dry_run, bulk_size
)

Create missing FR ContactRequest records from Turing for rated conversations.

Source code in components/support/public/commands/backfill_fr_contact_requests_from_turing.py
@support.command()
@command_with_dry_run
@click.option(
    "--bulk-size",
    type=int,
    default=500,
    help="Number of Turing rows to process per batch",
)
def backfill_fr_contact_requests_from_turing(
    dry_run: bool,
    bulk_size: int,
) -> None:
    """Create missing FR ContactRequest records from Turing for rated conversations."""
    from components.support.internal.business_logic.actions.backfill_fr_contact_requests_from_turing import (
        backfill_fr_contact_requests_from_turing as _backfill,
    )

    _backfill(dry_run=dry_run, bulk_size=bulk_size)

fix_fr_contact_request_data_2025_09_15

fix_fr_contact_request_data_2025_09_15

fix_fr_contact_request_data_2025_09_15(*, dry_run=True)

Following a run of import_fr_legacy_data that included a bug that set the US conversation id on contact_requests instead of the EU conversation id (but with the EU workspace id) when updating existing contact_requests, around 16000 contact_requests were affected (most of them from Sept 2024, around the time of the migration from the US workspace to the EU workspace, and when we had a bunch of conversations with both EU and US conversation ids).

This command fixes the data by setting the correct conversation id (the EU one) on the contact_requests.

Source code in components/support/public/commands/fix_fr_contact_request_data_2025_09_15.py
@support.command()
@command_with_dry_run
def fix_fr_contact_request_data_2025_09_15(*, dry_run: bool = True) -> None:
    """
    Following a run of import_fr_legacy_data that included a bug that set the US conversation id
    on contact_requests instead of the EU conversation id (but with the EU workspace id) when updating
    existing contact_requests, around 16000 contact_requests were affected (most of them from Sept 2024,
    around the time of the migration from the US workspace to the EU workspace, and when we had a bunch
    of conversations with both EU and US conversation ids).

    This command fixes the data by setting the correct conversation id (the EU one) on the contact_requests.
    """
    from components.support.internal.business_logic.actions.fix_fr_contact_request_data import (
        fix_fr_contact_request_data,
    )

    try:
        fix_fr_contact_request_data()
        current_logger.info(
            "Successfully fixed contact requests" + (" (dry run)" if dry_run else "")
        )
    except Exception as e:
        current_logger.error(f"Failed to import intercom admins: {str(e)}")
        raise

import_fr_intercom_admins

import_fr_intercom_admins

import_fr_intercom_admins(
    *, dry_run=True, only_intercom_admin_id=None
)

Import intercom admins into support agents.

This command imports data from the intercom_admin model into support_agent and support_agent_company models. It maps the relevant fields and creates the necessary relationships.

Parameters:

Name Type Description Default
dry_run bool

Whether to run in dry-run mode (no database changes). Defaults to True.

True
only_intercom_admin_id str

If provided, only update the specified intercom admin. Defaults to None.

None
Source code in components/support/public/commands/import_fr_intercom_admins.py
@support.command()
@click.option(
    "--only-intercom-admin-id",
    help="Optional intercom admin ID to only update a specific admin",
    required=False,
    type=str,
)
@command_with_dry_run
def import_fr_intercom_admins(
    *, dry_run: bool = True, only_intercom_admin_id: str | None = None
) -> None:
    """Import intercom admins into support agents.

    This command imports data from the intercom_admin model into support_agent and support_agent_company models.
    It maps the relevant fields and creates the necessary relationships.

    Args:
        dry_run (bool, optional): Whether to run in dry-run mode (no database changes). Defaults to True.
        only_intercom_admin_id (str, optional): If provided, only update the specified intercom admin. Defaults to None.
    """
    from components.support.internal.business_logic.actions.import_fr_intercom_admins.import_fr_intercom_admins import (
        import_fr_intercom_admins_to_support_agents,
    )

    try:
        import_fr_intercom_admins_to_support_agents(
            save=not dry_run, only_for_intercom_admin_id=only_intercom_admin_id
        )
        current_logger.info(
            "Successfully imported intercom admins" + (" (dry run)" if dry_run else "")
        )
    except Exception as e:
        current_logger.error(f"Failed to import intercom admins: {str(e)}")
        raise

import_fr_intercom_spe_inboxes

import_fr_intercom_spe_inboxes

import_fr_intercom_spe_inboxes(*, dry_run=True)

Import all existing intercom_spe_inbox entries into support.support_specialization.

Source code in components/support/public/commands/import_fr_intercom_spe_inboxes.py
@support.command()
@command_with_dry_run
def import_fr_intercom_spe_inboxes(*, dry_run: bool = True) -> None:
    """Import all existing intercom_spe_inbox entries into support.support_specialization."""
    from components.support.internal.business_logic.actions.import_fr_intercom_admins.import_fr_intercom_spe_inboxes_to_support_specializations import (
        import_fr_intercom_spe_inboxes_to_support_specializations,
    )

    try:
        import_fr_intercom_spe_inboxes_to_support_specializations(save=not dry_run)
        current_logger.info(
            "Successfully imported intercom_spe_inboxes into support.support_specialization"
            + (" (dry run)" if dry_run else "")
        )
    except Exception as e:
        current_logger.error(f"Failed to import intercom_spe_inboxes: {str(e)}")
        raise

import_fr_support_agents_from_turing

import_fr_support_agents_from_turing

import_fr_support_agents_from_turing(dry_run, verbose)

Import support agents from a Turing table that's filled with data coming from a Google Sheet. In the future we want to replace this with support endpoint calls from eu_tools directly (which contains the source for Alan employees & external agents)

Source code in components/support/public/commands/import_fr_support_agents_from_turing.py
@support.command()
@command_with_dry_run
@click.option(
    "--verbose",
    is_flag=True,
    help="Show verbose output. If not set, a progress bar will be shown.",
)
def import_fr_support_agents_from_turing(dry_run: bool, verbose: bool) -> None:
    """
    Import support agents from a Turing table that's filled with data coming
    from a Google Sheet. In the future we want to replace this with support endpoint calls
    from eu_tools directly (which contains the source for Alan employees & external agents)
    """
    from components.support.internal.business_logic.actions.import_fr_support_agents_from_turing import (
        import_fr_support_agents_from_turing,
    )

    import_fr_support_agents_from_turing(commit=not dry_run, verbose=verbose)

import_intercom_inboxes_command

import_intercom_inboxes

import_intercom_inboxes(
    *, dry_run=True, intercom_workspace_id
)

Import Intercom inboxes from a given Intercom workspace.

Parameters:

Name Type Description Default
dry_run bool

Whether to run in dry-run mode (no database changes). Defaults to True.

True
intercom_workspace_id str

The Intercom workspace ID to import inboxes from.

required
Source code in components/support/public/commands/import_intercom_inboxes_command.py
@support.command()
@click.option(
    "--intercom-workspace-id",
    help="The Intercom workspace ID to import inboxes from.",
    required=True,
    type=str,
)
@command_with_dry_run
def import_intercom_inboxes(
    *, dry_run: bool = True, intercom_workspace_id: str
) -> None:
    """Import Intercom inboxes from a given Intercom workspace.

    Args:
        dry_run (bool, optional): Whether to run in dry-run mode (no database changes). Defaults to True.
        intercom_workspace_id (str): The Intercom workspace ID to import inboxes from.
    """
    from components.support.internal.business_logic.actions.import_intercom_inboxes import (
        import_support_agent_spes_from_workspace_inboxes,
    )

    try:
        import_support_agent_spes_from_workspace_inboxes(
            intercom_workspace_id=intercom_workspace_id, commit=not dry_run
        )
        current_logger.info(
            f"Successfully imported Intercom inboxes from workspace {intercom_workspace_id}"
            + (" (dry run)" if dry_run else "")
        )
    except Exception as e:
        current_logger.error(f"Failed to import Intercom inboxes: {str(e)}")
        raise

import_legacy_intercom_csat_data

import_legacy_intercom_csat_data

import_legacy_intercom_csat_data(
    dry_run, country, bulk_size
)

Import legacy Intercom CSAT ratings from Turing into SupportCSAT table.

Source code in components/support/public/commands/import_legacy_intercom_csat_data.py
@support.command()
@command_with_dry_run
@click.option(
    "--country",
    type=click.Choice(["fr", "be", "es", "ca"]),
    required=True,
    help="Country code (fr, be, es, ca)",
)
@click.option(
    "--bulk-size",
    type=int,
    default=500,
    help="Number of Turing rows to process per batch",
)
def import_legacy_intercom_csat_data(
    dry_run: bool,
    country: str,
    bulk_size: int,
) -> None:
    """Import legacy Intercom CSAT ratings from Turing into SupportCSAT table."""
    from components.support.internal.business_logic.actions.import_legacy_intercom_csat_data import (
        import_legacy_intercom_csat_data as _import,
    )

    _import(country=country, dry_run=dry_run, bulk_size=bulk_size)

import_support_agents_from_employees_command

import_support_agents_from_employees

import_support_agents_from_employees(dry_run, verbose)

Import support agents from employees into SupportAgent and SupportAgentWorkspaceAffectation. Creates or updates SupportAgent entries and their associated workspace affectations.

Source code in components/support/public/commands/import_support_agents_from_employees_command.py
@support.command()
@command_with_dry_run
@click.option(
    "--verbose",
    is_flag=True,
    help="Show verbose output. If not set, a progress bar will be shown.",
)
def import_support_agents_from_employees(dry_run: bool, verbose: bool) -> None:
    """
    Import support agents from employees into SupportAgent and SupportAgentWorkspaceAffectation.
    Creates or updates SupportAgent entries and their associated workspace affectations.
    """
    from components.support.internal.business_logic.actions.import_support_agents_from_employees import (
        import_support_agents_from_employees,
    )

    if not _is_employee_import_command_active(get_current_app_name()):
        current_logger.info(
            "Not running import_support_agents_from_employees - feature flag for country is disabled"
        )
        return

    try:
        import_support_agents_from_employees(commit=not dry_run, verbose=verbose)
        current_logger.info(
            "Successfully imported support agents from employees"
            + (" (dry run)" if dry_run else "")
        )
    except Exception as e:
        current_logger.error(f"Failed to import support agents: {str(e)}")
        raise

intercom_fallback

intercom_deferred_push_notifications

send_deferred_intercom_push_notifications
send_deferred_intercom_push_notifications(
    lookback_hours=14,
)

Send push notifications for admin messages missed by the Intercom webhook.

Runs at support opening time to deliver deferred push notifications. Skips silently if support is currently closed.

Only processes conversations where the member has not yet been notified about the latest admin message (last_admin_reply_at > last_member_notified_at).

Parameters:

Name Type Description Default
lookback_hours int

Look back this many hours for unnotified messages. Defaults to 14h.

14
Source code in components/support/public/commands/intercom_fallback/intercom_deferred_push_notifications.py
@support.command()
@click.option(
    "-l",
    "--lookback-hours",
    default=14,
    type=int,
    help="Look for unnotified admin messages in the last X hours. Defaults to 14h to cover overnight.",
)
def send_deferred_intercom_push_notifications(
    lookback_hours: int = 14,
) -> None:
    """Send push notifications for admin messages missed by the Intercom webhook.

    Runs at support opening time to deliver deferred push notifications.
    Skips silently if support is currently closed.

    Only processes conversations where the member has not yet been notified about
    the latest admin message (last_admin_reply_at > last_member_notified_at).

    Args:
        lookback_hours: Look back this many hours for unnotified messages. Defaults to 14h.
    """
    from components.support.internal.business_logic.actions.intercom_fallback.deferred_push_notifications import (
        send_deferred_push_notifications_for_missed_admin_messages,
    )

    send_deferred_push_notifications_for_missed_admin_messages(
        lookback_hours=lookback_hours,
    )

intercom_new_conversation_fallback

process_new_conversations_not_sent_by_intercom
process_new_conversations_not_sent_by_intercom(
    dry_run, lookback_minutes=10
)

Process new conversations not sent by Intercom.

This command gets a list of conversations from Intercom that have been created in the past {lookback-minutes} and processes them to make sure we haven't missed any in case of Intercom errors (webhook not fired, Alan service temporarily unavailable, etc)

Parameters:

Name Type Description Default
dry_run bool

Whether to run in dry-run mode (no database changes). Defaults to True.

required
lookback_minutes int

If provided, get conversations from Intercom created in the last X minutes. Defaults to 10.

10
Source code in components/support/public/commands/intercom_fallback/intercom_new_conversation_fallback.py
@support.command()
@click.option(
    "-l",
    "--lookback-minutes",
    default=10,
    type=int,
    help="Lookback x minutes from now. Defaults to 10min.",
)
@command_with_dry_run
def process_new_conversations_not_sent_by_intercom(
    dry_run: bool,
    lookback_minutes: int = 10,
) -> None:
    """Process new conversations not sent by Intercom.

    This command gets a list of conversations from Intercom that have been created in the past
    {lookback-minutes} and processes them to make sure we haven't missed any in case of
    Intercom errors (webhook not fired, Alan service temporarily unavailable, etc)

    Args:
        dry_run (bool, optional): Whether to run in dry-run mode (no database changes). Defaults to True.
        lookback_minutes (int, optional): If provided, get conversations from Intercom created in the last X minutes. Defaults to 10.
    """
    import datetime
    import time

    from components.support.internal.business_logic.actions.intercom_fallback.process_new_conversation_fallback import (
        find_and_process_new_intercom_conversations_not_sent_by_intercom,
        is_fallback_command_active,
    )

    current_app_name = get_current_app_name()
    if not is_fallback_command_active(current_app_name):
        return
    # give ourselves a buffer of 1 min to process new conversations "normally"
    buffer = 60  # seconds
    end_unix_time = int(time.mktime(datetime.datetime.now().timetuple())) - buffer
    start_unix_time = end_unix_time - (lookback_minutes * 60)
    intercom_client = get_care_intercom_client(
        current_app_name, throttle_delay_seconds=0.5
    )
    find_and_process_new_intercom_conversations_not_sent_by_intercom(
        start_unix_time=start_unix_time,
        end_unix_time=end_unix_time,
        intercom_client=intercom_client,
        dry_run=dry_run,
    )

intercom_update_fallback

sync_conversation_updates_not_sent_by_intercom
sync_conversation_updates_not_sent_by_intercom(
    dry_run, end_offset_seconds=0, lookback_hours=6
)

Process conversation updates not sent by Intercom.

This command gets a list of conversations from Intercom that have been updated in the past {lookback-hours} and processes them (syncs the DB) to make sure we haven't missed any in case of Intercom errors (webhook not fired, Alan service temporarily unavailable, etc)

Parameters:

Name Type Description Default
dry_run bool

Whether to run in dry-run mode (no database changes). Defaults to True.

required
lookback_hours int

If provided, get conversations from Intercom updated in the last X hours. Defaults to 6.

6
end_offset_seconds int

start at datetime.now() - end-offset-seconds. This allows running the command from cron, disregarding recent conversations. Defaults to 0.

0
Source code in components/support/public/commands/intercom_fallback/intercom_update_fallback.py
@support.command()
@click.option(
    "-o",
    "--end-offset-seconds",
    default=0,
    type=int,
    help="when no end-unix-time is given, start at datetime.now() - end-offset-seconds. This allows running the command from cron, disregarding recent conversations. Defaults to 0.",
)
@click.option(
    "-l",
    "--lookback-hours",
    default=6,
    type=int,
    help="Lookback x hours from computed end time. Defaults to 6h.",
)
@command_with_dry_run
def sync_conversation_updates_not_sent_by_intercom(
    dry_run: bool,
    end_offset_seconds: int = 0,
    lookback_hours: int = 6,
) -> None:
    """Process conversation updates not sent by Intercom.

    This command gets a list of conversations from Intercom that have been updated in the past
    {lookback-hours} and processes them (syncs the DB) to make sure we haven't missed any in case of
    Intercom errors (webhook not fired, Alan service temporarily unavailable, etc)

    Args:
        dry_run (bool, optional): Whether to run in dry-run mode (no database changes). Defaults to True.
        lookback_hours (int, optional): If provided, get conversations from Intercom updated in the last X hours. Defaults to 6.
        end_offset_seconds (int, optional): start at datetime.now() - end-offset-seconds. This allows running the command from cron, disregarding recent conversations. Defaults to 0.
    """
    import datetime
    import time

    from components.support.internal.business_logic.actions.intercom_fallback.update_conversation_fallback import (
        find_and_sync_db_for_intercom_conversation_updates_not_sent_by_intercom,
    )

    current_app_name = get_current_app_name()
    if not is_fallback_command_active(current_app_name):
        return

    end_unix_time = (
        int(time.mktime(datetime.datetime.now().timetuple())) - end_offset_seconds
    )
    start_unix_time = end_unix_time - (lookback_hours * 3600)
    intercom_client = get_care_intercom_client(
        current_app_name, throttle_delay_seconds=0.5
    )
    find_and_sync_db_for_intercom_conversation_updates_not_sent_by_intercom(
        start_unix_time=start_unix_time,
        end_unix_time=end_unix_time,
        intercom_client=intercom_client,
        dry_run=dry_run,
    )

synchronize_support_specializations

synchronize_support_specializations

synchronize_support_specializations(dry_run)

Synchronize support specializations from Intercom to our backend.

Source code in components/support/public/commands/synchronize_support_specializations.py
@support.command()
@command_with_dry_run
@do_not_run_in_prod
def synchronize_support_specializations(dry_run: bool) -> None:
    """
    Synchronize support specializations from Intercom to our backend.
    """
    from components.support.internal.business_logic.actions.synchronize_support_specializations_actions import (
        synchronize_support_specializations,
    )

    synchronize_support_specializations(commit=not dry_run)

components.support.public.data

assigner_sql_queries

BE_ASSIGNER_SQL_QUERY module-attribute

BE_ASSIGNER_SQL_QUERY = "\n    WITH support_agents_for_computation AS (\n        SELECT\n            sa.id as support_agent_id,\n            sa.intercom_admin_id,\n            sawa.level,\n            sawa.intercom_workspace_id,\n            --Roles are now stored in an array, so we check for the presence of a role in the array.\n            'eligible_for_sync' = ANY(sawa.roles) as is_eligible_for_sync,\n            CASE WHEN (NOW() AT TIME ZONE 'Europe/Paris')::TIME BETWEEN '09:00:00' AND '17:00:00'\n                THEN 'eligible_for_async' = ANY(sawa.roles)\n                ELSE True\n            END as is_eligible_for_async,\n            NOT sa.is_external_admin AS has_role_uce,\n            CASE WHEN (NOW() AT TIME ZONE 'Europe/Paris')::TIME BETWEEN '09:00:00' AND '17:00:00'\n                THEN 'eligible_for_callback' = ANY(sawa.roles)\n                ELSE False\n            END as has_role_callback,\n            'en' = ANY(sa.spoken_languages) as speaks_english,\n            'fr' = ANY(sa.spoken_languages) as speaks_french,\n            'nl' = ANY(sa.spoken_languages) as speaks_dutch\n        FROM support.support_agent as sa\n        JOIN support.support_agent_workspace_affectation sawa ON sa.id = sawa.support_agent_id AND sawa.intercom_workspace_id = (:workspace_id)\n        WHERE sa.id = (:support_agent_id)  -- the admin requesting an assignment\n    ),\n    uce_specialisations_mapping AS (\n      SELECT sa.intercom_admin_id\n      , ss.intercom_inbox_id\n      , sasms.score\n      FROM support_agents_for_computation sa\n      LEFT JOIN support.support_agent_workspace_affectation sawa ON sawa.support_agent_id = sa.support_agent_id AND sawa.intercom_workspace_id = (:workspace_id)\n      LEFT JOIN support.support_agent_spe_matching_score sasms ON sasms.support_agent_workspace_affectation_id = sawa.id\n      LEFT JOIN support.support_specialization ss ON ss.id = sasms.support_specialization_id\n      WHERE COALESCE(sasms.score, 0.2) != 0\n    ),\n    prefiltered_contact_request_backlog AS (\n        SELECT\n            DISTINCT ON (cr.intercom_conversation_id)\n            cr.id as contact_request_id,\n            cr.intercom_conversation_id,\n            CASE\n\t\t\t\tWHEN cris.assigned_intercom_inbox_id IS NULL AND cris.assigned_intercom_admin_id IS NULL AND (:default_inbox_id) IS NOT NULL\n\t\t\t\tTHEN (:default_inbox_id)\n\t\t\t\tELSE cris.assigned_intercom_inbox_id\n            END as inbox_id,\n            cris.last_inbox_assignment_by,\n            cris.waiting_since,\n            greatest(cris.waiting_since, cris.last_admin_reply_at) AS time_reference_for_priority_score,\n            cris.last_admin_reply_at,\n            cr.source_type as type,\n            cram.recommended_admin_id,\n            cris.started_at,\n            cris.state,\n            cris.assigned_intercom_admin_id as admin_id,\n            cr.created_at,\n            -- using tags to compute properties of the conversations\n            CASE\n                WHEN tags.sla_tag = 'SLA[12h]' or tags.sla_tag = 'ASYNC' THEN 'SLA[12h]'\n                WHEN tags.sla_tag = 'SLA[5min]' or tags.sla_tag = 'SYNC' THEN 'SLA[5min]'\n                WHEN tags.sla_tag = 'SLA[30min]' OR tags.callback_tag = 'ROLE[CALLBACK]' THEN 'SLA[30min]'\n                WHEN cr.source_type in ('async_conversation_request', 'unknown') THEN 'SLA[12h]'\n                ELSE 'SLA[5min]'\n            END AS priority_level,\n            CASE\n                WHEN tags.workforce_level_tag = 'ROLE[LEVEL3]' OR tags.uce_reserved_tag IS NOT NULL THEN 3\n                WHEN tags.workforce_level_tag = 'ROLE[LEVEL2]' THEN 2\n                ELSE 1\n            END AS conversation_workforce_level,\n            tags.*\n        FROM support.contact_request cr\n        JOIN support.contact_request_intercom_state cris ON cr.id = cris.contact_request_id\n        LEFT JOIN support.contact_request_assignment_metadata cram ON cr.id = cram.contact_request_id\n        LEFT JOIN support.contact_request_tag crt ON cr.id = crt.contact_request_id\n        CROSS JOIN LATERAL (\n            SELECT\n                MAX(CASE WHEN crt.name in ('ROLE[CALLBACK]', '#OTHER - CARE TEAM - Eligible call-back request') THEN crt.name END) AS callback_tag,\n                MAX(CASE WHEN crt.name in ('ROLE[DUTCH]') THEN crt.name END) AS dutch_tag,\n                MAX(CASE WHEN crt.name in ('ROLE[ENGLISH]') THEN crt.name END) AS english_tag,\n                MAX(CASE WHEN crt.name in ('ROLE[FRENCH]') THEN crt.name END) AS french_tag,\n                MAX(CASE WHEN crt.name IN ('SLA[5min]', 'SLA[30min]', 'SLA[12h]', 'SYNC', 'ASYNC') THEN crt.name END) as sla_tag,\n                MAX(CASE WHEN crt.name IN ('ROLE[LEVEL1]','ROLE[LEVEL2]','ROLE[LEVEL3]') THEN crt.name END) AS workforce_level_tag,\n                MAX(CASE WHEN crt.name = 'SENSITIVE' THEN crt.name END) AS uce_reserved_tag,\n                MAX(CASE WHEN crt.name = 'bypass-assigner' THEN crt.name END) AS bypass_tag\n            FROM support.contact_request_tag crt\n            WHERE crt.contact_request_id = cr.id\n        ) tags\n        WHERE\n            cr.intercom_workspace_id = (:workspace_id)\n            AND cris.state = 'open'\n            AND (cris.assigned_intercom_admin_id IS NULL OR cris.assigned_intercom_admin_id = '') -- testing for empty string for records updated via flask admin\n            AND tags.bypass_tag IS NULL -- exclude convos explicitly flagged as bypassing the Assigner\n        ORDER BY cr.intercom_conversation_id, cr.id, crt.notified_at DESC\n    ),\n\n    eligible_contact_request_backlog_with_priority_level AS (\n        SELECT\n            pcrb.contact_request_id,\n            pcrb.intercom_conversation_id,\n            pcrb.inbox_id,\n            pcrb.last_inbox_assignment_by,\n            pcrb.time_reference_for_priority_score,\n            pcrb.priority_level,\n            pcrb.conversation_workforce_level,\n            sa.level - pcrb.conversation_workforce_level AS distance_workforce_level_admin_conversation,\n            CASE WHEN pcrb.recommended_admin_id = sa.intercom_admin_id THEN (:member_matching_score_boost) ELSE 1 END AS uce_member_matching_score,\n            pcrb.sla_tag,\n            pcrb.started_at\n        FROM prefiltered_contact_request_backlog pcrb\n        CROSS JOIN support_agents_for_computation sa\n        WHERE sa.support_agent_id = (:support_agent_id) -- join only on the admin requesting an assignment\n            -- reduce scope for agents depending on their sync/async eligibility configuration\n            AND (\n                    (COALESCE(sa.is_eligible_for_sync, True) AND pcrb.priority_level = 'SLA[5min]' AND pcrb.callback_tag IS NULL)\n                    OR\n                    (COALESCE(sa.is_eligible_for_async, True) AND pcrb.priority_level != 'SLA[5min]' AND pcrb.callback_tag IS NULL)\n                    OR\n                    (COALESCE(sa.has_role_callback, True) AND pcrb.callback_tag IS NOT NULL)\n            )\n            -- reduce scope for agents not speaking English when not ASYNC\n            AND (COALESCE(sa.speaks_english, True) OR pcrb.english_tag IS NULL OR (pcrb.priority_level = 'SLA[12h]' AND sa.has_role_uce))\n            -- reduce scope for agents not speaking Dutch when not ASYNC\n            AND (COALESCE(sa.speaks_dutch, True) OR pcrb.dutch_tag IS NULL OR (pcrb.priority_level = 'SLA[12h]' AND sa.has_role_uce))\n            -- reduce scope for agents not speaking French when not ASYNC\n            AND (COALESCE(sa.speaks_french, True) OR pcrb.french_tag IS NULL OR (pcrb.priority_level = 'SLA[12h]' AND sa.has_role_uce))\n    ),\n    eligible_contact_request_backlog_with_context AS (\n        SELECT\n            ecrb.contact_request_id,\n            ecrb.intercom_conversation_id,\n            ecrb.priority_level,\n            sa.intercom_admin_id,\n            CASE\n                WHEN ecrb.priority_level = 'SLA[5min]'\n                    THEN (:sync_sla_score_boost) * (3 + count_business_minutes_between(ecrb.time_reference_for_priority_score, now()))  / (5 - 2)\n                WHEN ecrb.priority_level = 'SLA[30min]'\n                    THEN (3 + count_business_minutes_between(ecrb.time_reference_for_priority_score, now()))  / (30 - 2)\n                ELSE --  priority_level = 'SLA[12h]'\n                        (3 + count_business_minutes_between(ecrb.time_reference_for_priority_score, now()))  / (12 * 60 - 2)\n            END AS sla_score,\n            CASE\n                WHEN ecrb.priority_level = 'SLA[5min]'\n                    THEN count_business_minutes_between(ecrb.time_reference_for_priority_score, now()) >= 5\n                WHEN ecrb.priority_level = 'SLA[30min]'\n                    THEN count_business_minutes_between(ecrb.time_reference_for_priority_score, now()) >= 30\n                ELSE\n                    count_business_minutes_between(ecrb.time_reference_for_priority_score, now()) >= 12 * 60\n            END AS sla_breached,\n            distance_workforce_level_admin_conversation,\n            CASE\n                WHEN distance_workforce_level_admin_conversation = 0 THEN (:workforce_level_score_boost)\n                WHEN distance_workforce_level_admin_conversation < 0 THEN NULL\n                ELSE 1.0 / (distance_workforce_level_admin_conversation + 1)\n            END AS workforce_level_matching_score,\n            ecrb.inbox_id,\n            ss.intercom_inbox_name as inbox_name,\n            ecrb.last_inbox_assignment_by,\n            ecrb.uce_member_matching_score,\n            ecrb.time_reference_for_priority_score,\n            ecrb.sla_tag,\n            ecrb.conversation_workforce_level,\n            CASE WHEN ecrb.priority_level = 'SLA[5min]' AND sa.level < 3 THEN 2 ELSE 0 END as additional_power_weight_c\n        FROM eligible_contact_request_backlog_with_priority_level ecrb\n        INNER JOIN support.support_specialization ss on ss.intercom_inbox_id = ecrb.inbox_id AND ss.is_used_by_assigner\n        CROSS JOIN support_agents_for_computation sa\n        WHERE\n            sa.level - ecrb.conversation_workforce_level >= 0\n            AND CASE\n                    WHEN ecrb.priority_level = 'SLA[5min]' THEN COALESCE(ecrb.started_at < now() at time zone 'utc' - '30 seconds'::interval, TRUE)\n                    ELSE COALESCE(ecrb.started_at < now() at time zone 'utc' - '2 minutes'::interval, TRUE)\n                END\n    ),\n    eligible_convos_with_scores AS (\n        SELECT\n            ec.contact_request_id,\n            ec.intercom_conversation_id,\n            ec.inbox_id,\n            ec.inbox_name,\n            NULL as reserved_for_external_agent_platform,\n            ec.time_reference_for_priority_score,\n            m.score AS expertise_matching_score,\n            m.score AS raw_expertise_matching_score,\n            POWER(sla_score, (:power_weight_c) + ec.additional_power_weight_c) AS priority_score,\n            POWER(workforce_level_matching_score, (:power_weight_d)) AS workforce_level_matching_score,\n            uce_member_matching_score,\n            COALESCE(\n                    power(m.score, (:power_weight_a))\n                    * POWER(sla_score, (:power_weight_c) + ec.additional_power_weight_c)\n                    * POWER(workforce_level_matching_score, (:power_weight_d))\n                    * uce_member_matching_score\n                , 0) AS assignment_score,\n            sla_score,\n            sla_breached,\n            MAX(POWER(sla_score, (:power_weight_c) + ec.additional_power_weight_c)) OVER () AS max_priority_score,\n            priority_level,\n            MAX(POWER(sla_score, (:power_weight_c) + ec.additional_power_weight_c)) OVER (PARTITION BY priority_level) AS max_priority_score_per_priority_level,\n            null as n_rejections,\n            ec.sla_tag,\n            ec.priority_level = 'SLA[5min]' as is_sync_conversation,\n            ec.additional_power_weight_c\n        FROM eligible_contact_request_backlog_with_context ec\n        LEFT JOIN support.contact_request_assignment_event crae ON crae.contact_request_id = ec.contact_request_id AND crae.action = 'rejected' AND crae.intercom_admin_id = ec.intercom_admin_id\n        LEFT JOIN uce_specialisations_mapping m ON m.intercom_inbox_id = ec.inbox_id\n        WHERE crae.contact_request_id IS NULL\n          AND COALESCE(m.score, 0.2) >= 0\n    ),\n    backlog_stats AS MATERIALIZED (\n        SELECT\n            count(*) as n_assignable_convos,\n            count(*) FILTER (WHERE priority_level = 'SLA[12h]') as n_assignable_async,\n            count(*) FILTER (WHERE priority_level = 'SLA[5min]') as n_assignable_sync,\n            count(*) FILTER (WHERE priority_level = 'SLA[30min]') as n_assignable_callback,\n            count(*) FILTER (WHERE priority_level = 'SLA[12h]' AND sla_breached) as n_assignable_async_sla_breached,\n            count(*) FILTER (WHERE priority_level = 'SLA[5min]' AND sla_breached) as n_assignable_sync_sla_breached,\n            count(*) FILTER (WHERE priority_level = 'SLA[30min]' AND sla_breached) as n_assignable_callback_sla_breached,\n            percentile_cont(0.9) WITHIN GROUP (ORDER BY CASE WHEN priority_level = 'SLA[12h]' THEN power(sla_score, (:power_weight_c) + additional_power_weight_c) END) AS q90_priority_score_sla_12h,\n            percentile_cont(0.9) WITHIN GROUP (ORDER BY CASE WHEN priority_level = 'SLA[5min]' THEN power(sla_score, (:power_weight_c) + additional_power_weight_c) END) AS q90_priority_score_sla_5m,\n            percentile_cont(0.9) WITHIN GROUP (ORDER BY CASE WHEN priority_level = 'SLA[30min]' THEN power(sla_score, (:power_weight_c) + additional_power_weight_c) END) AS q90_priority_score_sla_30m,\n            percentile_cont(0.9) WITHIN GROUP (ORDER BY CASE WHEN priority_level = 'SLA[12h]' THEN expertise_matching_score END) AS q90_expertise_score_sla_12h,\n            percentile_cont(0.9) WITHIN GROUP (ORDER BY CASE WHEN priority_level = 'SLA[5min]' THEN expertise_matching_score END) AS q90_expertise_score_sla_5m,\n            percentile_cont(0.9) WITHIN GROUP (ORDER BY CASE WHEN priority_level = 'SLA[30min]' THEN expertise_matching_score END) AS q90_expertise_score_sla_30m\n        FROM eligible_convos_with_scores\n    )\n    SELECT *, 1.0 AS shift_matching_score, NULL as marrakech_scope_protection_boost\n    FROM eligible_convos_with_scores\n    CROSS JOIN backlog_stats stats\n    ORDER BY assignment_score DESC NULLS LAST, time_reference_for_priority_score\n    LIMIT 10;\n    "

BE_RANKED_SQL_QUERY module-attribute

BE_RANKED_SQL_QUERY = "\n    WITH support_agents_for_computation AS (\n        SELECT\n            sa.id as support_agent_id,\n            sa.intercom_admin_id,\n            sawa.level,\n            sawa.intercom_workspace_id,\n            --Roles are now stored in an array, so we check for the presence of a role in the array.\n            'eligible_for_sync' = ANY(sawa.roles) as is_eligible_for_sync,\n            CASE WHEN (NOW() AT TIME ZONE 'Europe/Paris')::TIME BETWEEN '09:00:00' AND '17:00:00'\n                THEN 'eligible_for_async' = ANY(sawa.roles)\n                ELSE True\n            END as is_eligible_for_async,\n            NOT sa.is_external_admin AS has_role_uce,\n            CASE WHEN (NOW() AT TIME ZONE 'Europe/Paris')::TIME BETWEEN '09:00:00' AND '17:00:00'\n                THEN 'eligible_for_callback' = ANY(sawa.roles)\n                ELSE False\n            END as has_role_callback,\n            'en' = ANY(sa.spoken_languages) as speaks_english,\n            'fr' = ANY(sa.spoken_languages) as speaks_french,\n            'nl' = ANY(sa.spoken_languages) as speaks_dutch\n        FROM support.support_agent as sa\n        JOIN support.support_agent_workspace_affectation sawa ON sa.id = sawa.support_agent_id AND sawa.intercom_workspace_id = (:workspace_id)\n        WHERE sa.id = (:support_agent_id)  -- the admin requesting an assignment\n    ),\n    uce_specialisations_mapping AS (\n      SELECT sa.intercom_admin_id\n      , ss.intercom_inbox_id\n      , sasms.score\n      FROM support_agents_for_computation sa\n      LEFT JOIN support.support_agent_workspace_affectation sawa ON sawa.support_agent_id = sa.support_agent_id AND sawa.intercom_workspace_id = (:workspace_id)\n      LEFT JOIN support.support_agent_spe_matching_score sasms ON sasms.support_agent_workspace_affectation_id = sawa.id\n      LEFT JOIN support.support_specialization ss ON ss.id = sasms.support_specialization_id\n      WHERE COALESCE(sasms.score, 0.2) != 0\n    ),\n    prefiltered_contact_request_backlog AS (\n        SELECT\n            DISTINCT ON (cr.intercom_conversation_id)\n            cr.id as contact_request_id,\n            cr.intercom_conversation_id,\n            CASE\n\t\t\t\tWHEN cris.assigned_intercom_inbox_id IS NULL AND cris.assigned_intercom_admin_id IS NULL AND (:default_inbox_id) IS NOT NULL\n\t\t\t\tTHEN (:default_inbox_id)\n\t\t\t\tELSE cris.assigned_intercom_inbox_id\n            END as inbox_id,\n            cris.last_inbox_assignment_by,\n            cris.waiting_since,\n            greatest(cris.waiting_since, cris.last_admin_reply_at) AS time_reference_for_priority_score,\n            cris.last_admin_reply_at,\n            cr.source_type as type,\n            cram.recommended_admin_id,\n            cris.started_at,\n            cris.state,\n            cris.assigned_intercom_admin_id as admin_id,\n            cr.created_at,\n            -- using tags to compute properties of the conversations\n            CASE\n                WHEN tags.sla_tag = 'SLA[12h]' or tags.sla_tag = 'ASYNC' THEN 'SLA[12h]'\n                WHEN tags.sla_tag = 'SLA[5min]' or tags.sla_tag = 'SYNC' THEN 'SLA[5min]'\n                WHEN tags.sla_tag = 'SLA[30min]' OR tags.callback_tag = 'ROLE[CALLBACK]' THEN 'SLA[30min]'\n                WHEN cr.source_type in ('async_conversation_request', 'unknown') THEN 'SLA[12h]'\n                ELSE 'SLA[5min]'\n            END AS priority_level,\n            CASE\n                WHEN tags.workforce_level_tag = 'ROLE[LEVEL3]' OR tags.uce_reserved_tag IS NOT NULL THEN 3\n                WHEN tags.workforce_level_tag = 'ROLE[LEVEL2]' THEN 2\n                ELSE 1\n            END AS conversation_workforce_level,\n            tags.*\n        FROM support.contact_request cr\n        JOIN support.contact_request_intercom_state cris ON cr.id = cris.contact_request_id\n        LEFT JOIN support.contact_request_assignment_metadata cram ON cr.id = cram.contact_request_id\n        LEFT JOIN support.contact_request_tag crt ON cr.id = crt.contact_request_id\n        CROSS JOIN LATERAL (\n            SELECT\n                MAX(CASE WHEN crt.name in ('ROLE[CALLBACK]', '#OTHER - CARE TEAM - Eligible call-back request') THEN crt.name END) AS callback_tag,\n                MAX(CASE WHEN crt.name in ('ROLE[DUTCH]') THEN crt.name END) AS dutch_tag,\n                MAX(CASE WHEN crt.name in ('ROLE[ENGLISH]') THEN crt.name END) AS english_tag,\n                MAX(CASE WHEN crt.name in ('ROLE[FRENCH]') THEN crt.name END) AS french_tag,\n                MAX(CASE WHEN crt.name IN ('SLA[5min]', 'SLA[30min]', 'SLA[12h]', 'SYNC', 'ASYNC') THEN crt.name END) as sla_tag,\n                MAX(CASE WHEN crt.name IN ('ROLE[LEVEL1]','ROLE[LEVEL2]','ROLE[LEVEL3]') THEN crt.name END) AS workforce_level_tag,\n                MAX(CASE WHEN crt.name = 'SENSITIVE' THEN crt.name END) AS uce_reserved_tag,\n                MAX(CASE WHEN crt.name = 'bypass-assigner' THEN crt.name END) AS bypass_tag\n            FROM support.contact_request_tag crt\n            WHERE crt.contact_request_id = cr.id\n        ) tags\n        WHERE\n            cr.intercom_workspace_id = (:workspace_id)\n            AND cris.state = 'open'\n            AND (cris.assigned_intercom_admin_id IS NULL OR cris.assigned_intercom_admin_id = '') -- testing for empty string for records updated via flask admin\n            AND tags.bypass_tag IS NULL -- exclude convos explicitly flagged as bypassing the Assigner\n        ORDER BY cr.intercom_conversation_id, cr.id, crt.notified_at DESC\n    ),\n\n    eligible_contact_request_backlog_with_priority_level AS (\n        SELECT\n            pcrb.contact_request_id,\n            pcrb.intercom_conversation_id,\n            pcrb.inbox_id,\n            pcrb.last_inbox_assignment_by,\n            pcrb.time_reference_for_priority_score,\n            pcrb.priority_level,\n            pcrb.conversation_workforce_level,\n            sa.level - pcrb.conversation_workforce_level AS distance_workforce_level_admin_conversation,\n            CASE WHEN pcrb.recommended_admin_id = sa.intercom_admin_id THEN (:member_matching_score_boost) ELSE 1 END AS uce_member_matching_score,\n            pcrb.sla_tag,\n            pcrb.started_at\n        FROM prefiltered_contact_request_backlog pcrb\n        CROSS JOIN support_agents_for_computation sa\n        WHERE sa.support_agent_id = (:support_agent_id) -- join only on the admin requesting an assignment\n            -- reduce scope for agents depending on their sync/async eligibility configuration\n            AND (\n                    (COALESCE(sa.is_eligible_for_sync, True) AND pcrb.priority_level = 'SLA[5min]' AND pcrb.callback_tag IS NULL)\n                    OR\n                    (COALESCE(sa.is_eligible_for_async, True) AND pcrb.priority_level != 'SLA[5min]' AND pcrb.callback_tag IS NULL)\n                    OR\n                    (COALESCE(sa.has_role_callback, True) AND pcrb.callback_tag IS NOT NULL)\n            )\n            -- reduce scope for agents not speaking English when not ASYNC\n            AND (COALESCE(sa.speaks_english, True) OR pcrb.english_tag IS NULL OR (pcrb.priority_level = 'SLA[12h]' AND sa.has_role_uce))\n            -- reduce scope for agents not speaking Dutch when not ASYNC\n            AND (COALESCE(sa.speaks_dutch, True) OR pcrb.dutch_tag IS NULL OR (pcrb.priority_level = 'SLA[12h]' AND sa.has_role_uce))\n            -- reduce scope for agents not speaking French when not ASYNC\n            AND (COALESCE(sa.speaks_french, True) OR pcrb.french_tag IS NULL OR (pcrb.priority_level = 'SLA[12h]' AND sa.has_role_uce))\n    ),\n    eligible_contact_request_backlog_with_context AS (\n        SELECT\n            ecrb.contact_request_id,\n            ecrb.intercom_conversation_id,\n            ecrb.priority_level,\n            sa.intercom_admin_id,\n            CASE\n                WHEN ecrb.priority_level = 'SLA[5min]'\n                    THEN (:sync_sla_score_boost) * (3 + count_business_minutes_between(ecrb.time_reference_for_priority_score, now()))  / (5 - 2)\n                WHEN ecrb.priority_level = 'SLA[30min]'\n                    THEN (3 + count_business_minutes_between(ecrb.time_reference_for_priority_score, now()))  / (30 - 2)\n                ELSE --  priority_level = 'SLA[12h]'\n                        (3 + count_business_minutes_between(ecrb.time_reference_for_priority_score, now()))  / (12 * 60 - 2)\n            END AS sla_score,\n            CASE\n                WHEN ecrb.priority_level = 'SLA[5min]'\n                    THEN count_business_minutes_between(ecrb.time_reference_for_priority_score, now()) >= 5\n                WHEN ecrb.priority_level = 'SLA[30min]'\n                    THEN count_business_minutes_between(ecrb.time_reference_for_priority_score, now()) >= 30\n                ELSE\n                    count_business_minutes_between(ecrb.time_reference_for_priority_score, now()) >= 12 * 60\n            END AS sla_breached,\n            distance_workforce_level_admin_conversation,\n            CASE\n                WHEN distance_workforce_level_admin_conversation = 0 THEN (:workforce_level_score_boost)\n                WHEN distance_workforce_level_admin_conversation < 0 THEN NULL\n                ELSE 1.0 / (distance_workforce_level_admin_conversation + 1)\n            END AS workforce_level_matching_score,\n            ecrb.inbox_id,\n            ss.intercom_inbox_name as inbox_name,\n            ecrb.last_inbox_assignment_by,\n            ecrb.uce_member_matching_score,\n            ecrb.time_reference_for_priority_score,\n            ecrb.sla_tag,\n            ecrb.conversation_workforce_level,\n            CASE WHEN ecrb.priority_level = 'SLA[5min]' AND sa.level < 3 THEN 2 ELSE 0 END as additional_power_weight_c\n        FROM eligible_contact_request_backlog_with_priority_level ecrb\n        INNER JOIN support.support_specialization ss on ss.intercom_inbox_id = ecrb.inbox_id AND ss.is_used_by_assigner\n        CROSS JOIN support_agents_for_computation sa\n        WHERE\n            sa.level - ecrb.conversation_workforce_level >= 0\n            AND CASE\n                    WHEN ecrb.priority_level = 'SLA[5min]' THEN COALESCE(ecrb.started_at < now() at time zone 'utc' - '30 seconds'::interval, TRUE)\n                    ELSE COALESCE(ecrb.started_at < now() at time zone 'utc' - '2 minutes'::interval, TRUE)\n                END\n    ),\n    eligible_convos_with_scores AS (\n        SELECT\n            ec.contact_request_id,\n            ec.intercom_conversation_id,\n            ec.inbox_id,\n            ec.inbox_name,\n            NULL as reserved_for_external_agent_platform,\n            ec.time_reference_for_priority_score,\n            m.score AS expertise_matching_score,\n            m.score AS raw_expertise_matching_score,\n            POWER(sla_score, (:power_weight_c) + ec.additional_power_weight_c) AS priority_score,\n            POWER(workforce_level_matching_score, (:power_weight_d)) AS workforce_level_matching_score,\n            uce_member_matching_score,\n            COALESCE(\n                    power(m.score, (:power_weight_a))\n                    * POWER(sla_score, (:power_weight_c) + ec.additional_power_weight_c)\n                    * POWER(workforce_level_matching_score, (:power_weight_d))\n                    * uce_member_matching_score\n                , 0) AS assignment_score,\n            sla_score,\n            sla_breached,\n            MAX(POWER(sla_score, (:power_weight_c) + ec.additional_power_weight_c)) OVER () AS max_priority_score,\n            priority_level,\n            MAX(POWER(sla_score, (:power_weight_c) + ec.additional_power_weight_c)) OVER (PARTITION BY priority_level) AS max_priority_score_per_priority_level,\n            null as n_rejections,\n            ec.sla_tag,\n            ec.priority_level = 'SLA[5min]' as is_sync_conversation,\n            ec.additional_power_weight_c\n        FROM eligible_contact_request_backlog_with_context ec\n        LEFT JOIN support.contact_request_assignment_event crae ON crae.contact_request_id = ec.contact_request_id AND crae.action = 'rejected' AND crae.intercom_admin_id = ec.intercom_admin_id\n        LEFT JOIN uce_specialisations_mapping m ON m.intercom_inbox_id = ec.inbox_id\n        WHERE crae.contact_request_id IS NULL\n          AND COALESCE(m.score, 0.2) >= 0\n    ),\n    backlog_stats AS MATERIALIZED (\n        SELECT\n            count(*) as n_assignable_convos,\n            count(*) FILTER (WHERE priority_level = 'SLA[12h]') as n_assignable_async,\n            count(*) FILTER (WHERE priority_level = 'SLA[5min]') as n_assignable_sync,\n            count(*) FILTER (WHERE priority_level = 'SLA[30min]') as n_assignable_callback,\n            count(*) FILTER (WHERE priority_level = 'SLA[12h]' AND sla_breached) as n_assignable_async_sla_breached,\n            count(*) FILTER (WHERE priority_level = 'SLA[5min]' AND sla_breached) as n_assignable_sync_sla_breached,\n            count(*) FILTER (WHERE priority_level = 'SLA[30min]' AND sla_breached) as n_assignable_callback_sla_breached,\n            percentile_cont(0.9) WITHIN GROUP (ORDER BY CASE WHEN priority_level = 'SLA[12h]' THEN power(sla_score, (:power_weight_c) + additional_power_weight_c) END) AS q90_priority_score_sla_12h,\n            percentile_cont(0.9) WITHIN GROUP (ORDER BY CASE WHEN priority_level = 'SLA[5min]' THEN power(sla_score, (:power_weight_c) + additional_power_weight_c) END) AS q90_priority_score_sla_5m,\n            percentile_cont(0.9) WITHIN GROUP (ORDER BY CASE WHEN priority_level = 'SLA[30min]' THEN power(sla_score, (:power_weight_c) + additional_power_weight_c) END) AS q90_priority_score_sla_30m,\n            percentile_cont(0.9) WITHIN GROUP (ORDER BY CASE WHEN priority_level = 'SLA[12h]' THEN expertise_matching_score END) AS q90_expertise_score_sla_12h,\n            percentile_cont(0.9) WITHIN GROUP (ORDER BY CASE WHEN priority_level = 'SLA[5min]' THEN expertise_matching_score END) AS q90_expertise_score_sla_5m,\n            percentile_cont(0.9) WITHIN GROUP (ORDER BY CASE WHEN priority_level = 'SLA[30min]' THEN expertise_matching_score END) AS q90_expertise_score_sla_30m\n        FROM eligible_convos_with_scores\n    )\n    SELECT\n        row_number() OVER (ORDER BY assignment_score DESC NULLS LAST, time_reference_for_priority_score) AS assignment_rank\n        , (:support_agent_id)\n        , *\n        , row_number() OVER (ORDER BY intercom_conversation_id = (:conversation_id) DESC, assignment_score DESC NULLS LAST, time_reference_for_priority_score) AS row_nb\n    FROM eligible_convos_with_scores\n    CROSS JOIN backlog_stats stats\n    ORDER BY row_nb, assignment_score DESC NULLS LAST, time_reference_for_priority_score\n    LIMIT 10;\n    "

ES_ASSIGNER_SQL_QUERY module-attribute

ES_ASSIGNER_SQL_QUERY = "\n    WITH support_agents_for_computation AS (\n        SELECT\n            sa.id as support_agent_id,\n            sa.intercom_admin_id,\n            sawa.level,\n            sawa.intercom_workspace_id,\n            --Roles are now stored in an array, so we check for the presence of a role in the array.\n            'eligible_for_sync' = ANY(sawa.roles) as is_eligible_for_sync,\n            CASE WHEN (NOW() AT TIME ZONE 'Europe/Madrid')::TIME BETWEEN '09:00:00' AND '18:00:00'\n                THEN 'eligible_for_async' = ANY(sawa.roles)\n                ELSE True\n            END as is_eligible_for_async,\n            CASE WHEN (NOW() AT TIME ZONE 'Europe/Madrid')::TIME BETWEEN '09:00:00' AND '18:00:00'\n                THEN 'eligible_for_callback' = ANY(sawa.roles)\n                ELSE False\n            END as has_role_callback, -- not used yet in SP yet but keep it for consistency\n            NOT sa.is_external_admin AS has_role_uce,\n            'en' = ANY(sa.spoken_languages) as speaks_english,\n            'es' = ANY(sa.spoken_languages) as speaks_spanish,\n            'ca' = ANY(sa.spoken_languages) as speaks_catalan\n        FROM support.support_agent as sa\n        JOIN support.support_agent_workspace_affectation sawa on sa.id = sawa.support_agent_id\n        WHERE sa.id = (:support_agent_id)  -- the admin requesting an assignment\n            AND sawa.intercom_workspace_id = (:workspace_id)\n    ),\n    uce_specialisations_mapping AS (\n        SELECT sa.intercom_admin_id\n        , ss.intercom_inbox_id\n        , sasms.score\n        FROM support_agents_for_computation sa\n        LEFT JOIN support.support_agent_workspace_affectation sawa ON sawa.support_agent_id = sa.support_agent_id AND sawa.intercom_workspace_id = (:workspace_id)\n        LEFT JOIN support.support_agent_spe_matching_score sasms ON sasms.support_agent_workspace_affectation_id = sawa.id\n        LEFT JOIN support.support_specialization ss ON ss.id = sasms.support_specialization_id\n        WHERE COALESCE(sasms.score, 0.2) != 0\n    ),\n    prefiltered_contact_request_backlog AS (\n        SELECT\n            DISTINCT ON (cr.intercom_conversation_id)\n            cr.id as contact_request_id,\n            cr.intercom_conversation_id,\n            CASE\n\t\t\t\tWHEN cris.assigned_intercom_inbox_id IS NULL AND cris.assigned_intercom_admin_id IS NULL AND (:default_inbox_id) IS NOT NULL\n\t\t\t\tTHEN (:default_inbox_id)\n\t\t\t\tELSE cris.assigned_intercom_inbox_id\n            END as inbox_id,\n            cris.last_inbox_assignment_by,\n            cris.waiting_since,\n            greatest(cris.waiting_since, cris.last_admin_reply_at) AS time_reference_for_priority_score,\n            cris.last_admin_reply_at,\n            cr.source_type as type,\n            cram.recommended_admin_id,\n            cris.started_at,\n            cris.state,\n            cris.assigned_intercom_admin_id as admin_id,\n            cr.created_at,\n            -- using tags to compute properties of the conversations\n            CASE\n                WHEN tags.sla_tag = 'SLA[12h]' or tags.sla_tag = 'ASYNC' THEN 'SLA[12h]'\n                WHEN tags.sla_tag = 'SLA[5min]' or tags.sla_tag = 'SYNC' THEN 'SLA[5min]'\n                WHEN tags.sla_tag = 'SLA[30min]' THEN 'SLA[30min]'\n                WHEN cr.source_type = 'async_conversation_request' THEN 'SLA[12h]'\n                ELSE 'SLA[5min]'\n            END AS priority_level,\n            CASE\n                WHEN tags.workforce_level_tag = 'ROLE[LEVEL3]' THEN 3\n                WHEN tags.workforce_level_tag = 'ROLE[LEVEL2]' THEN 2\n                ELSE 1\n            END AS conversation_workforce_level,\n            tags.*\n        FROM support.contact_request cr\n        JOIN support.contact_request_intercom_state cris ON cr.id = cris.contact_request_id\n        LEFT JOIN support.contact_request_assignment_metadata cram ON cr.id = cram.contact_request_id\n        LEFT JOIN support.contact_request_tag crt ON cr.id = crt.contact_request_id\n        CROSS JOIN LATERAL (\n            SELECT\n                MAX(CASE WHEN crt.name = 'ROLE[SPANISH]' THEN crt.name END) AS spanish_tag,\n                MAX(CASE WHEN crt.name = 'ROLE[ENGLISH]' THEN crt.name END) AS english_tag,\n                MAX(CASE WHEN crt.name = 'ROLE[CATALAN]' THEN crt.name END) AS catalan_tag,\n                MAX(CASE WHEN crt.name IN ('SLA[5min]', 'SLA[30min]', 'SLA[12h]', 'SYNC', 'ASYNC') THEN crt.name END) as sla_tag,\n                MAX(CASE WHEN crt.name IN ('ROLE[LEVEL1]','ROLE[LEVEL2]','ROLE[LEVEL3]') THEN crt.name END) AS workforce_level_tag,\n                MAX(CASE WHEN crt.name = 'bypass-assigner' THEN crt.name END) AS bypass_tag\n            FROM support.contact_request_tag crt\n            WHERE crt.contact_request_id = cr.id\n        ) tags\n        WHERE\n            cr.intercom_workspace_id = (:workspace_id)\n            AND cris.state = 'open'\n            AND (cris.assigned_intercom_admin_id IS NULL OR cris.assigned_intercom_admin_id = '') -- testing for empty string for records updated via flask admin\n            AND tags.bypass_tag IS NULL -- exclude convos explicitly flagged as bypassing the Assigner\n        ORDER BY cr.intercom_conversation_id, cr.id, crt.notified_at DESC\n    ),\n    eligible_contact_request_backlog_with_priority_level AS (\n        SELECT\n            pcrb.contact_request_id,\n            pcrb.intercom_conversation_id,\n            pcrb.inbox_id,\n            pcrb.last_inbox_assignment_by,\n            pcrb.time_reference_for_priority_score,\n            pcrb.priority_level,\n            pcrb.conversation_workforce_level,\n            sa.level - pcrb.conversation_workforce_level AS distance_workforce_level_admin_conversation,\n            CASE WHEN pcrb.recommended_admin_id = sa.intercom_admin_id THEN (:member_matching_score_boost) ELSE 1 END AS uce_member_matching_score,\n            pcrb.sla_tag,\n            pcrb.started_at\n        FROM prefiltered_contact_request_backlog pcrb\n        CROSS JOIN support_agents_for_computation sa\n        WHERE sa.support_agent_id = (:support_agent_id) -- join only on the admin requesting an assignment\n            -- reduce scope for agents depending on their sync/async eligibility configuration\n            AND (\n                    (COALESCE(sa.is_eligible_for_sync, True) AND pcrb.priority_level = 'SLA[5min]')\n                    OR\n                    (COALESCE(sa.is_eligible_for_async, True) AND pcrb.priority_level != 'SLA[5min]')\n            )\n            -- reduce scope for agents not speaking English\n            AND (COALESCE(sa.speaks_english, True) OR pcrb.english_tag IS NULL)\n            -- reduce scope for agents not speaking Spanish\n            AND (COALESCE(sa.speaks_spanish, True) OR pcrb.spanish_tag IS NULL)\n            -- reduce scope for agents not speaking Catalan\n            AND (COALESCE(sa.speaks_catalan, True) OR pcrb.catalan_tag IS NULL)\n    ),\n    eligible_contact_request_backlog_with_context AS (\n        SELECT\n            ecrb.contact_request_id,\n            ecrb.intercom_conversation_id,\n            ecrb.priority_level,\n            sa.intercom_admin_id,\n            CASE\n                WHEN ecrb.priority_level = 'SLA[5min]'\n                    THEN (:sync_sla_score_boost) * (3 + count_business_minutes_between(ecrb.time_reference_for_priority_score, now()))  / (5 - 2)\n                WHEN ecrb.priority_level = 'SLA[30min]'\n                    THEN (3 + count_business_minutes_between(ecrb.time_reference_for_priority_score, now()))  / (30 - 2)\n                ELSE --  priority_level = 'SLA[12h]'\n                        (3 + count_business_minutes_between(ecrb.time_reference_for_priority_score, now()))  / (12 * 60 - 2)\n            END AS sla_score,\n            CASE\n                WHEN ecrb.priority_level = 'SLA[5min]'\n                    THEN count_business_minutes_between(ecrb.time_reference_for_priority_score, now()) >= 5\n                WHEN ecrb.priority_level = 'SLA[30min]'\n                    THEN count_business_minutes_between(ecrb.time_reference_for_priority_score, now()) >= 30\n                ELSE\n                    count_business_minutes_between(ecrb.time_reference_for_priority_score, now()) >= 12 * 60\n            END AS sla_breached,\n            distance_workforce_level_admin_conversation,\n            CASE\n                WHEN distance_workforce_level_admin_conversation = 0 THEN (:workforce_level_score_boost)\n                WHEN distance_workforce_level_admin_conversation < 0 THEN NULL\n                ELSE 1.0 / (distance_workforce_level_admin_conversation + 1)\n            END AS workforce_level_matching_score,\n            ecrb.inbox_id,\n            ss.intercom_inbox_name as inbox_name,\n            ecrb.last_inbox_assignment_by,\n            ecrb.uce_member_matching_score,\n            ecrb.time_reference_for_priority_score,\n            ecrb.sla_tag,\n            ecrb.conversation_workforce_level,\n            CASE WHEN ecrb.priority_level = 'SLA[5min]' AND sa.level < 3 THEN 2 ELSE 0 END as additional_power_weight_c\n        FROM eligible_contact_request_backlog_with_priority_level ecrb\n        INNER JOIN support.support_specialization ss on ss.intercom_inbox_id = ecrb.inbox_id AND ss.is_used_by_assigner\n        CROSS JOIN support_agents_for_computation sa\n        WHERE\n            sa.level - ecrb.conversation_workforce_level >= 0\n            AND CASE\n                    WHEN ecrb.priority_level = 'SLA[5min]' THEN COALESCE(ecrb.started_at < now() at time zone 'utc' - '30 seconds'::interval, TRUE)\n                    ELSE COALESCE(ecrb.started_at < now() at time zone 'utc' - '2 minutes'::interval, TRUE)\n                END\n    ),\n    eligible_convos_with_scores AS (\n        SELECT\n            ec.contact_request_id,\n            ec.intercom_conversation_id,\n            ec.inbox_id,\n            ec.inbox_name,\n            NULL AS reserved_for_external_agent_platform,\n            ec.time_reference_for_priority_score,\n            m.score AS expertise_matching_score,\n            m.score AS raw_expertise_matching_score,\n            POWER(sla_score, (:power_weight_c) + ec.additional_power_weight_c) AS priority_score,\n            POWER(workforce_level_matching_score, (:power_weight_d)) AS workforce_level_matching_score,\n            uce_member_matching_score,\n            COALESCE(\n                    power(m.score, ((:power_weight_a)))\n                    * POWER(sla_score, (:power_weight_c) + ec.additional_power_weight_c)\n                    * POWER(workforce_level_matching_score, (:power_weight_d))\n                    * uce_member_matching_score\n                , 0) AS assignment_score,\n            sla_score,\n            sla_breached,\n            MAX(POWER(sla_score, (:power_weight_c) + ec.additional_power_weight_c)) OVER () AS max_priority_score,\n            priority_level,\n            MAX(POWER(sla_score, (:power_weight_c) + ec.additional_power_weight_c)) OVER (PARTITION BY priority_level) AS max_priority_score_per_priority_level,\n            null as n_rejections,\n            ec.sla_tag,\n            ec.priority_level = 'SLA[5min]' as is_sync_conversation,\n            ec.additional_power_weight_c\n        FROM eligible_contact_request_backlog_with_context ec\n        LEFT JOIN support.contact_request_assignment_event crae ON crae.contact_request_id = ec.contact_request_id AND crae.action = 'rejected' AND crae.intercom_admin_id = ec.intercom_admin_id\n        LEFT JOIN uce_specialisations_mapping m ON m.intercom_inbox_id = ec.inbox_id\n        WHERE crae.contact_request_id IS NULL\n        AND COALESCE(m.score, 0.2) >= 0\n    ),\n    backlog_stats AS MATERIALIZED (\n        SELECT\n            count(*) as n_assignable_convos,\n            count(*) FILTER (WHERE priority_level = 'SLA[12h]') as n_assignable_async,\n            count(*) FILTER (WHERE priority_level = 'SLA[5min]') as n_assignable_sync,\n            count(*) FILTER (WHERE priority_level = 'SLA[30min]') as n_assignable_callback,\n            count(*) FILTER (WHERE priority_level = 'SLA[12h]' AND sla_breached) as n_assignable_async_sla_breached,\n            count(*) FILTER (WHERE priority_level = 'SLA[5min]' AND sla_breached) as n_assignable_sync_sla_breached,\n            count(*) FILTER (WHERE priority_level = 'SLA[30min]' AND sla_breached) as n_assignable_callback_sla_breached,\n            percentile_cont(0.9) WITHIN GROUP (ORDER BY CASE WHEN priority_level = 'SLA[12h]' THEN power(sla_score, (:power_weight_c) + additional_power_weight_c) END) AS q90_priority_score_sla_12h,\n            percentile_cont(0.9) WITHIN GROUP (ORDER BY CASE WHEN priority_level = 'SLA[5min]' THEN power(sla_score, (:power_weight_c) + additional_power_weight_c) END) AS q90_priority_score_sla_5m,\n            percentile_cont(0.9) WITHIN GROUP (ORDER BY CASE WHEN priority_level = 'SLA[30min]' THEN power(sla_score, (:power_weight_c) + additional_power_weight_c) END) AS q90_priority_score_sla_30m,\n            percentile_cont(0.9) WITHIN GROUP (ORDER BY CASE WHEN priority_level = 'SLA[12h]' THEN expertise_matching_score END) AS q90_expertise_score_sla_12h,\n            percentile_cont(0.9) WITHIN GROUP (ORDER BY CASE WHEN priority_level = 'SLA[5min]' THEN expertise_matching_score END) AS q90_expertise_score_sla_5m,\n            percentile_cont(0.9) WITHIN GROUP (ORDER BY CASE WHEN priority_level = 'SLA[30min]' THEN expertise_matching_score END) AS q90_expertise_score_sla_30m\n        FROM eligible_convos_with_scores\n    )\n    SELECT *, 1.0 AS shift_matching_score, NULL as marrakech_scope_protection_boost\n    FROM eligible_convos_with_scores\n    CROSS JOIN backlog_stats stats\n    ORDER BY assignment_score DESC NULLS LAST, time_reference_for_priority_score\n    LIMIT 10;\n    "

ES_RANKED_SQL_QUERY module-attribute

ES_RANKED_SQL_QUERY = "\n    WITH support_agents_for_computation AS (\n        SELECT\n            sa.id as support_agent_id,\n            sa.intercom_admin_id,\n            sawa.level,\n            sawa.intercom_workspace_id,\n            --Roles are now stored in an array, so we check for the presence of a role in the array.\n            'eligible_for_sync' = ANY(sawa.roles) as is_eligible_for_sync,\n            CASE WHEN (NOW() AT TIME ZONE 'Europe/Madrid')::TIME BETWEEN '09:00:00' AND '18:00:00'\n                THEN 'eligible_for_async' = ANY(sawa.roles)\n                ELSE True\n            END as is_eligible_for_async,\n            CASE WHEN (NOW() AT TIME ZONE 'Europe/Madrid')::TIME BETWEEN '09:00:00' AND '18:00:00'\n                THEN 'eligible_for_callback' = ANY(sawa.roles)\n                ELSE False\n            END as has_role_callback, -- not used yet in SP yet but keep it for consistency\n            NOT sa.is_external_admin AS has_role_uce,\n            'en' = ANY(sa.spoken_languages) as speaks_english,\n            'es' = ANY(sa.spoken_languages) as speaks_spanish,\n            'ca' = ANY(sa.spoken_languages) as speaks_catalan\n        FROM support.support_agent as sa\n        JOIN support.support_agent_workspace_affectation sawa on sa.id = sawa.support_agent_id\n        WHERE sa.id = (:support_agent_id)  -- the admin requesting an assignment\n            AND sawa.intercom_workspace_id = (:workspace_id)\n    ),\n    uce_specialisations_mapping AS (\n        SELECT sa.intercom_admin_id\n        , ss.intercom_inbox_id\n        , sasms.score\n        FROM support_agents_for_computation sa\n        LEFT JOIN support.support_agent_workspace_affectation sawa ON sawa.support_agent_id = sa.support_agent_id AND sawa.intercom_workspace_id = (:workspace_id)\n        LEFT JOIN support.support_agent_spe_matching_score sasms ON sasms.support_agent_workspace_affectation_id = sawa.id\n        LEFT JOIN support.support_specialization ss ON ss.id = sasms.support_specialization_id\n        WHERE COALESCE(sasms.score, 0.2) != 0\n    ),\n    prefiltered_contact_request_backlog AS (\n        SELECT\n            DISTINCT ON (cr.intercom_conversation_id)\n            cr.id as contact_request_id,\n            cr.intercom_conversation_id,\n            CASE\n\t\t\t\tWHEN cris.assigned_intercom_inbox_id IS NULL AND cris.assigned_intercom_admin_id IS NULL AND (:default_inbox_id) IS NOT NULL\n\t\t\t\tTHEN (:default_inbox_id)\n\t\t\t\tELSE cris.assigned_intercom_inbox_id\n            END as inbox_id,\n            cris.last_inbox_assignment_by,\n            cris.waiting_since,\n            greatest(cris.waiting_since, cris.last_admin_reply_at) AS time_reference_for_priority_score,\n            cris.last_admin_reply_at,\n            cr.source_type as type,\n            cram.recommended_admin_id,\n            cris.started_at,\n            cris.state,\n            cris.assigned_intercom_admin_id as admin_id,\n            cr.created_at,\n            -- using tags to compute properties of the conversations\n            CASE\n                WHEN tags.sla_tag = 'SLA[12h]' or tags.sla_tag = 'ASYNC' THEN 'SLA[12h]'\n                WHEN tags.sla_tag = 'SLA[5min]' or tags.sla_tag = 'SYNC' THEN 'SLA[5min]'\n                WHEN tags.sla_tag = 'SLA[30min]' THEN 'SLA[30min]'\n                WHEN cr.source_type = 'async_conversation_request' THEN 'SLA[12h]'\n                ELSE 'SLA[5min]'\n            END AS priority_level,\n            CASE\n                WHEN tags.workforce_level_tag = 'ROLE[LEVEL3]' THEN 3\n                WHEN tags.workforce_level_tag = 'ROLE[LEVEL2]' THEN 2\n                ELSE 1\n            END AS conversation_workforce_level,\n            tags.*\n        FROM support.contact_request cr\n        JOIN support.contact_request_intercom_state cris ON cr.id = cris.contact_request_id\n        LEFT JOIN support.contact_request_assignment_metadata cram ON cr.id = cram.contact_request_id\n        LEFT JOIN support.contact_request_tag crt ON cr.id = crt.contact_request_id\n        CROSS JOIN LATERAL (\n            SELECT\n                MAX(CASE WHEN crt.name = 'ROLE[SPANISH]' THEN crt.name END) AS spanish_tag,\n                MAX(CASE WHEN crt.name = 'ROLE[ENGLISH]' THEN crt.name END) AS english_tag,\n                MAX(CASE WHEN crt.name = 'ROLE[CATALAN]' THEN crt.name END) AS catalan_tag,\n                MAX(CASE WHEN crt.name IN ('SLA[5min]', 'SLA[30min]', 'SLA[12h]', 'SYNC', 'ASYNC') THEN crt.name END) as sla_tag,\n                MAX(CASE WHEN crt.name IN ('ROLE[LEVEL1]','ROLE[LEVEL2]','ROLE[LEVEL3]') THEN crt.name END) AS workforce_level_tag,\n                MAX(CASE WHEN crt.name = 'bypass-assigner' THEN crt.name END) AS bypass_tag\n            FROM support.contact_request_tag crt\n            WHERE crt.contact_request_id = cr.id\n        ) tags\n        WHERE\n            cr.intercom_workspace_id = (:workspace_id)\n            AND cris.state = 'open'\n            AND (cris.assigned_intercom_admin_id IS NULL OR cris.assigned_intercom_admin_id = '') -- testing for empty string for records updated via flask admin\n            AND tags.bypass_tag IS NULL -- exclude convos explicitly flagged as bypassing the Assigner\n        ORDER BY cr.intercom_conversation_id, cr.id, crt.notified_at DESC\n    ),\n    eligible_contact_request_backlog_with_priority_level AS (\n        SELECT\n            pcrb.contact_request_id,\n            pcrb.intercom_conversation_id,\n            pcrb.inbox_id,\n            pcrb.last_inbox_assignment_by,\n            pcrb.time_reference_for_priority_score,\n            pcrb.priority_level,\n            pcrb.conversation_workforce_level,\n            sa.level - pcrb.conversation_workforce_level AS distance_workforce_level_admin_conversation,\n            CASE WHEN pcrb.recommended_admin_id = sa.intercom_admin_id THEN (:member_matching_score_boost) ELSE 1 END AS uce_member_matching_score,\n            pcrb.sla_tag,\n            pcrb.started_at\n        FROM prefiltered_contact_request_backlog pcrb\n        CROSS JOIN support_agents_for_computation sa\n        WHERE sa.support_agent_id = (:support_agent_id) -- join only on the admin requesting an assignment\n            -- reduce scope for agents depending on their sync/async eligibility configuration\n            AND (\n                    (COALESCE(sa.is_eligible_for_sync, True) AND pcrb.priority_level = 'SLA[5min]')\n                    OR\n                    (COALESCE(sa.is_eligible_for_async, True) AND pcrb.priority_level != 'SLA[5min]')\n            )\n            -- reduce scope for agents not speaking English\n            AND (COALESCE(sa.speaks_english, True) OR pcrb.english_tag IS NULL)\n            -- reduce scope for agents not speaking Spanish\n            AND (COALESCE(sa.speaks_spanish, True) OR pcrb.spanish_tag IS NULL)\n            -- reduce scope for agents not speaking Catalan\n            AND (COALESCE(sa.speaks_catalan, True) OR pcrb.catalan_tag IS NULL)\n    ),\n    eligible_contact_request_backlog_with_context AS (\n        SELECT\n            ecrb.contact_request_id,\n            ecrb.intercom_conversation_id,\n            ecrb.priority_level,\n            sa.intercom_admin_id,\n            CASE\n                WHEN ecrb.priority_level = 'SLA[5min]'\n                    THEN (:sync_sla_score_boost) * (3 + count_business_minutes_between(ecrb.time_reference_for_priority_score, now()))  / (5 - 2)\n                WHEN ecrb.priority_level = 'SLA[30min]'\n                    THEN (3 + count_business_minutes_between(ecrb.time_reference_for_priority_score, now()))  / (30 - 2)\n                ELSE --  priority_level = 'SLA[12h]'\n                        (3 + count_business_minutes_between(ecrb.time_reference_for_priority_score, now()))  / (12 * 60 - 2)\n            END AS sla_score,\n            CASE\n                WHEN ecrb.priority_level = 'SLA[5min]'\n                    THEN count_business_minutes_between(ecrb.time_reference_for_priority_score, now()) >= 5\n                WHEN ecrb.priority_level = 'SLA[30min]'\n                    THEN count_business_minutes_between(ecrb.time_reference_for_priority_score, now()) >= 30\n                ELSE\n                    count_business_minutes_between(ecrb.time_reference_for_priority_score, now()) >= 12 * 60\n            END AS sla_breached,\n            distance_workforce_level_admin_conversation,\n            CASE\n                WHEN distance_workforce_level_admin_conversation = 0 THEN (:workforce_level_score_boost)\n                WHEN distance_workforce_level_admin_conversation < 0 THEN NULL\n                ELSE 1.0 / (distance_workforce_level_admin_conversation + 1)\n            END AS workforce_level_matching_score,\n            ecrb.inbox_id,\n            ss.intercom_inbox_name as inbox_name,\n            ecrb.last_inbox_assignment_by,\n            ecrb.uce_member_matching_score,\n            ecrb.time_reference_for_priority_score,\n            ecrb.sla_tag,\n            ecrb.conversation_workforce_level,\n            CASE WHEN ecrb.priority_level = 'SLA[5min]' AND sa.level < 3 THEN 2 ELSE 0 END as additional_power_weight_c\n        FROM eligible_contact_request_backlog_with_priority_level ecrb\n        INNER JOIN support.support_specialization ss on ss.intercom_inbox_id = ecrb.inbox_id AND ss.is_used_by_assigner\n        CROSS JOIN support_agents_for_computation sa\n        WHERE\n            sa.level - ecrb.conversation_workforce_level >= 0\n            AND CASE\n                    WHEN ecrb.priority_level = 'SLA[5min]' THEN COALESCE(ecrb.started_at < now() at time zone 'utc' - '30 seconds'::interval, TRUE)\n                    ELSE COALESCE(ecrb.started_at < now() at time zone 'utc' - '2 minutes'::interval, TRUE)\n                END\n    ),\n    eligible_convos_with_scores AS (\n        SELECT\n            ec.contact_request_id,\n            ec.intercom_conversation_id,\n            ec.inbox_id,\n            ec.inbox_name,\n            NULL AS reserved_for_external_agent_platform,\n            ec.time_reference_for_priority_score,\n            m.score AS expertise_matching_score,\n            m.score AS raw_expertise_matching_score,\n            POWER(sla_score, (:power_weight_c) + ec.additional_power_weight_c) AS priority_score,\n            POWER(workforce_level_matching_score, (:power_weight_d)) AS workforce_level_matching_score,\n            uce_member_matching_score,\n            COALESCE(\n                    power(m.score, ((:power_weight_a)))\n                    * POWER(sla_score, (:power_weight_c) + ec.additional_power_weight_c)\n                    * POWER(workforce_level_matching_score, (:power_weight_d))\n                    * uce_member_matching_score\n                , 0) AS assignment_score,\n            sla_score,\n            sla_breached,\n            MAX(POWER(sla_score, (:power_weight_c) + ec.additional_power_weight_c)) OVER () AS max_priority_score,\n            priority_level,\n            MAX(POWER(sla_score, (:power_weight_c) + ec.additional_power_weight_c)) OVER (PARTITION BY priority_level) AS max_priority_score_per_priority_level,\n            null as n_rejections,\n            ec.sla_tag,\n            ec.priority_level = 'SLA[5min]' as is_sync_conversation,\n            ec.additional_power_weight_c\n        FROM eligible_contact_request_backlog_with_context ec\n        LEFT JOIN support.contact_request_assignment_event crae ON crae.contact_request_id = ec.contact_request_id AND crae.action = 'rejected' AND crae.intercom_admin_id = ec.intercom_admin_id\n        LEFT JOIN uce_specialisations_mapping m ON m.intercom_inbox_id = ec.inbox_id\n        WHERE crae.contact_request_id IS NULL\n        AND COALESCE(m.score, 0.2) >= 0\n    ),\n    backlog_stats AS MATERIALIZED (\n        SELECT\n            count(*) as n_assignable_convos,\n            count(*) FILTER (WHERE priority_level = 'SLA[12h]') as n_assignable_async,\n            count(*) FILTER (WHERE priority_level = 'SLA[5min]') as n_assignable_sync,\n            count(*) FILTER (WHERE priority_level = 'SLA[30min]') as n_assignable_callback,\n            count(*) FILTER (WHERE priority_level = 'SLA[12h]' AND sla_breached) as n_assignable_async_sla_breached,\n            count(*) FILTER (WHERE priority_level = 'SLA[5min]' AND sla_breached) as n_assignable_sync_sla_breached,\n            count(*) FILTER (WHERE priority_level = 'SLA[30min]' AND sla_breached) as n_assignable_callback_sla_breached,\n            percentile_cont(0.9) WITHIN GROUP (ORDER BY CASE WHEN priority_level = 'SLA[12h]' THEN power(sla_score, (:power_weight_c) + additional_power_weight_c) END) AS q90_priority_score_sla_12h,\n            percentile_cont(0.9) WITHIN GROUP (ORDER BY CASE WHEN priority_level = 'SLA[5min]' THEN power(sla_score, (:power_weight_c) + additional_power_weight_c) END) AS q90_priority_score_sla_5m,\n            percentile_cont(0.9) WITHIN GROUP (ORDER BY CASE WHEN priority_level = 'SLA[30min]' THEN power(sla_score, (:power_weight_c) + additional_power_weight_c) END) AS q90_priority_score_sla_30m,\n            percentile_cont(0.9) WITHIN GROUP (ORDER BY CASE WHEN priority_level = 'SLA[12h]' THEN expertise_matching_score END) AS q90_expertise_score_sla_12h,\n            percentile_cont(0.9) WITHIN GROUP (ORDER BY CASE WHEN priority_level = 'SLA[5min]' THEN expertise_matching_score END) AS q90_expertise_score_sla_5m,\n            percentile_cont(0.9) WITHIN GROUP (ORDER BY CASE WHEN priority_level = 'SLA[30min]' THEN expertise_matching_score END) AS q90_expertise_score_sla_30m\n        FROM eligible_convos_with_scores\n    )\n    SELECT\n        row_number() OVER (ORDER BY assignment_score DESC NULLS LAST, time_reference_for_priority_score) AS assignment_rank\n        , (:support_agent_id)\n        , *\n        , row_number() OVER (ORDER BY intercom_conversation_id = (:conversation_id) DESC, assignment_score DESC NULLS LAST, time_reference_for_priority_score) AS row_nb\n    FROM eligible_convos_with_scores\n    CROSS JOIN backlog_stats stats\n    ORDER BY row_nb, assignment_score DESC NULLS LAST, time_reference_for_priority_score\n    LIMIT 10;\n    "

FR_ASSIGNER_SQL_QUERY module-attribute

FR_ASSIGNER_SQL_QUERY = "\n    WITH recalibrated_support_agents AS (\n        SELECT\n            sa.id\n        FROM support.assigner_recalibration ar\n        JOIN support.support_agent sa ON ar.alan_email = sa.alan_email\n        JOIN support.support_agent_workspace_affectation sawa ON sa.id = sawa.support_agent_id AND ar.intercom_workspace_id = sawa.intercom_workspace_id\n        WHERE ar.intercom_workspace_id = (:workspace_id)\n    ),\n    support_agents_for_computation AS (\n        SELECT\n            sa.id as support_agent_id\n            , sa.intercom_admin_id\n            , sawa.level\n            , sawa.intercom_workspace_id\n            -- Roles are now stored in an array, so we check for the presence of a role in the array.\n            , 'eligible_for_sync' = ANY(sawa.roles) as is_eligible_for_sync\n            , CASE WHEN (NOW() AT TIME ZONE 'Europe/Paris')::TIME BETWEEN '09:00:00' AND '19:00:00'\n                THEN 'eligible_for_async' = ANY(sawa.roles)\n                ELSE True\n                END as is_eligible_for_async\n            , sa.platform_name as platform\n            , NOT sa.is_external_admin AS has_role_uce\n            , CASE WHEN (NOW() AT TIME ZONE 'Europe/Paris')::TIME BETWEEN '09:00:00' AND '19:00:00'\n                THEN 'eligible_for_callback' = ANY(sawa.roles)\n                ELSE False\n                END as has_role_callback\n            , 'automated_answer_reviewer' = ANY(sawa.roles) as has_reviewer_role\n            , 'en' = ANY(sa.spoken_languages) as speaks_english\n            , sa.work_location != 'fr' as is_offshore\n            , CASE WHEN sa.work_location != 'fr' AND sa.platform_name = 'Webhelp'\n                THEN TRUE\n                ELSE FALSE\n                END AS is_marrakech_agent\n        FROM support.support_agent as sa\n        JOIN support.support_agent_workspace_affectation sawa ON sa.id = sawa.support_agent_id AND sawa.intercom_workspace_id = (:workspace_id)\n        WHERE (sa.id = ANY((select array(SELECT id FROM recalibrated_support_agents))::uuid[])\n            OR sa.id = (:support_agent_id))  -- the admin requesting an assignment\n    ),\n    uce_specialisations_mapping AS (\n        SELECT sa.intercom_admin_id\n            , ss.intercom_inbox_id\n            , sasms.score\n        FROM support_agents_for_computation sa\n        LEFT JOIN support.support_agent_workspace_affectation sawa ON sawa.support_agent_id = sa.support_agent_id\n        LEFT JOIN support.support_agent_spe_matching_score sasms ON sasms.support_agent_workspace_affectation_id = sawa.id\n        LEFT JOIN support.support_specialization ss ON ss.id = sasms.support_specialization_id\n        WHERE sasms.score != 0\n    ),\n    prefiltered_contact_request_backlog AS (\n        SELECT\n            DISTINCT ON (cr.intercom_conversation_id)\n            cr.id as contact_request_id\n            , cr.intercom_conversation_id\n            , CASE\n                WHEN cris.assigned_intercom_inbox_id IS NULL AND cris.assigned_intercom_admin_id IS NULL AND (:default_inbox_id) IS NOT NULL\n                THEN (:default_inbox_id) -- defaults to 'Care team' inbox id\n                ELSE cris.assigned_intercom_inbox_id\n                END as inbox_id\n            , cris.last_inbox_assignment_by\n            , cris.waiting_since\n            , greatest(cris.waiting_since, cris.last_admin_reply_at) AS time_reference_for_priority_score\n            , cris.last_admin_reply_at\n            , cram.recommended_admin_id as recommended_admin_id\n            , cris.started_at\n            , COALESCE(cram.reserved_for_automated_answers_review, FALSE) as reserved_for_review\n            , cris.state\n            , cris.assigned_intercom_admin_id as admin_id\n            , cr.created_at\n            -- using tags to compute properties of the conversations\n            , SUBSTRING(tags.platform_tag, 'ROLE\\[(.*)\\]') AS reserved_for_external_agent_platform\n            -- priority level is defined by the sync/async nature of the convo but can be overwritten during a handover\n            , CASE\n                WHEN tags.sla_tag = 'SLA[12h]' THEN 'SLA[12h]'\n                WHEN tags.sla_tag = 'SLA[5min]' THEN 'SLA[5min]'\n                WHEN tags.sla_tag = 'SLA[30min]' OR tags.callback_tag = 'ROLE[CALLBACK]' THEN 'SLA[30min]'\n                -- if not sync tagged or if one of the hotline source type → mark async.\n                WHEN tags.type_tag != 'SYNC' or cr.source_type in ('immediate_callback_request', 'hotline_request')THEN 'SLA[12h]'\n                ELSE 'SLA[5min]'\n                END AS priority_level\n            -- workforce level of the conversation\n            , CASE\n                WHEN tags.workforce_level_tag = 'ROLE[LEVEL3]' OR tags.uce_reserved_tag IS NOT NULL THEN 3\n                WHEN tags.workforce_level_tag = 'ROLE[LEVEL2]' THEN 2\n                ELSE 1\n                END AS conversation_workforce_level\n            , tags.*\n        FROM support.contact_request cr\n        JOIN support.contact_request_intercom_state cris ON cr.id = cris.contact_request_id\n        LEFT JOIN support.contact_request_assignment_metadata cram ON cr.id = cram.contact_request_id\n        LEFT JOIN support.contact_request_tag crt ON cr.id = crt.contact_request_id -- to define priority level\n        CROSS JOIN LATERAL (\n            SELECT\n                MAX(CASE WHEN crt.name IN ('ROLE[UCE]', 'COMPLEX CLAIM', 'SENSITIVE') THEN crt.name END) AS uce_reserved_tag\n                , MAX(CASE WHEN crt.name = 'ROLE[CALLBACK]' THEN crt.name END) AS callback_tag\n                , MAX(CASE WHEN crt.name IN ('ROLE[WEBHELP]', 'ROLE[ONEPILOT]') THEN crt.name END) AS platform_tag\n                , MAX(CASE WHEN crt.name = 'ROLE[ENGLISH]' THEN crt.name END) AS english_tag\n                , MAX(CASE WHEN crt.name = 'ROLE[GEORESTRICTED]' THEN crt.name END) AS geo_tag\n                , MAX(CASE WHEN crt.name IN ('ROLE[LEVEL1]','ROLE[LEVEL2]','ROLE[LEVEL3]') THEN crt.name END) AS workforce_level_tag\n                , MAX(CASE WHEN crt.name IN ('bypass-assigner', 'Abusive Conversation') THEN crt.name END) AS bypass_tag\n                , MAX(CASE WHEN crt.name IN ('SLA[12h]', 'SLA[5min]', 'SLA[30min]') THEN crt.name END) as sla_tag\n                , MAX(CASE WHEN crt.name IN ('SYNC', 'ASYNC') THEN crt.name END) as type_tag\n                , MAX(CASE WHEN crt.name IN ('bypass-classification') THEN crt.name END) AS classification_bypass_tag\n            FROM support.contact_request_tag crt\n            WHERE crt.contact_request_id = cr.id\n        ) tags\n        WHERE\n            cr.intercom_workspace_id = (:workspace_id)\n            AND cris.state = 'open'\n            AND (cris.assigned_intercom_admin_id IS NULL OR cris.assigned_intercom_admin_id = '') -- testing for empty string for records updated via flask admin\n            AND cr.created_at > '2021-11-26' -- until data is cleared in prod\n            AND tags.bypass_tag IS NULL -- exclude convos explicitly flagged as bypassing the Assigner\n        ORDER BY cr.intercom_conversation_id, cr.id, crt.notified_at DESC\n    ),\n    eligible_contact_request_backlog_with_priority_level AS (\n        SELECT\n            pcrb.contact_request_id\n            , pcrb.intercom_conversation_id\n            , pcrb.inbox_id\n            , pcrb.last_inbox_assignment_by\n            , pcrb.time_reference_for_priority_score\n            , pcrb.priority_level\n            , pcrb.conversation_workforce_level\n            , sa.level - pcrb.conversation_workforce_level AS distance_workforce_level_admin_conversation\n            , CASE WHEN pcrb.recommended_admin_id = sa.intercom_admin_id\n                THEN (:member_matching_score_boost)\n                ELSE 1\n                END AS uce_member_matching_score\n            , pcrb.started_at\n            , pcrb.reserved_for_external_agent_platform\n            , pcrb.reserved_for_review\n            , pcrb.classification_bypass_tag IS NOT NULL as bypassed_classifier\n            -- marrakech scope fit is a boolean flag to indicate if the conversation is a good fit for a marrakech agent\n            -- basically an L1 convo, not georestricted, not english, not uce reserved, not reserved for review\n            , CASE  WHEN pcrb.callback_tag IS NULL\n                    AND pcrb.conversation_workforce_level = 1\n                    AND pcrb.geo_tag IS NULL\n                    AND pcrb.english_tag IS NULL\n                    AND pcrb.uce_reserved_tag IS NULL\n                    AND NOT pcrb.reserved_for_review\n                THEN TRUE\n                ELSE FALSE\n                END AS marrakech_scope_fit\n        FROM prefiltered_contact_request_backlog pcrb\n        CROSS JOIN support_agents_for_computation sa\n        WHERE sa.support_agent_id = (:support_agent_id) -- join only on the admin requesting an assignment\n            -- reduce scope for agents depending on their sync/async eligibility configuration\n            AND (\n                -- Admin is ELIGIBLE_FOR_SYNC and conversation is NOT ASYNC and NOT CALLBACK\n                (COALESCE(sa.is_eligible_for_sync, True) AND pcrb.priority_level = 'SLA[5min]' AND pcrb.callback_tag IS NULL)\n                OR\n                -- Admin is ELIGIBLE_FOR_ASYNC and conversation is NOT SLA[5min] and NOT CALLBACK\n                (COALESCE(sa.is_eligible_for_async, True) AND pcrb.priority_level != 'SLA[5min]' AND pcrb.callback_tag IS NULL)\n                OR\n                -- Admin has CALLBACK role and conversation is CALLBACK\n                (COALESCE(sa.has_role_callback, True) AND pcrb.callback_tag IS NOT NULL)\n            )\n            -- assign automated answer reviews for reviewers\n            AND (COALESCE(sa.has_reviewer_role, False) OR pcrb.reserved_for_review IS NOT TRUE)\n            -- reduce scope for agents not speaking english\n            AND (COALESCE(sa.speaks_english, True) OR pcrb.english_tag IS NULL)\n            -- reduce scope of georestricted convos for offshore agents\n            AND (COALESCE(NOT sa.is_offshore, False) OR pcrb.geo_tag IS NULL)\n            AND CASE\n                    WHEN NOT pcrb.reserved_for_review THEN sa.level - pcrb.conversation_workforce_level >= 0\n                    ELSE TRUE\n                END\n                -- handle conversations reserved for specific external agent platforms\n                AND CASE\n                        WHEN pcrb.reserved_for_external_agent_platform IS NOT NULL\n                        THEN UPPER(sa.platform) = UPPER(pcrb.reserved_for_external_agent_platform)\n                        ELSE TRUE\n                END\n    ),\n\n    contact_request_context_digest AS (\n        SELECT\n            cr.id as contact_request_id\n            , cr.duplicated_from_id as original_contact_request_id\n            , COALESCE(cr.classification_result->'raw_prediction_classes', cr.classification_result->'prediction'->'raw_prediction_classes' ) as raw_prediction_classes\n            , COALESCE(cr.classification_result->'raw_prediction', cr.classification_result->'prediction'->'raw_prediction') as raw_prediction\n            , created_at\n        FROM support.contact_request cr\n        INNER JOIN eligible_contact_request_backlog_with_priority_level e ON e.contact_request_id = cr.id\n        WHERE cr.intercom_workspace_id = (:workspace_id)\n    ),\n    classifier_classes AS (\n        SELECT\n            raw_prediction_classes AS classes\n        FROM contact_request_context_digest\n        WHERE jsonb_typeof(raw_prediction) = 'array'\n        ORDER BY created_at DESC\n        LIMIT 1\n    ),\n    classifier_classes_as_array AS (\n        SELECT ARRAY_AGG(cls) classes_array\n        FROM classifier_classes, JSONB_ARRAY_ELEMENTS_TEXT(classifier_classes.classes) cls\n    ),\n\n    -- # 1. define the eligible backlog for the UCE requesting an assignment\n    eligible_contact_request_backlog_with_context AS (\n        SELECT\n            ecrb.contact_request_id\n            , ecrb.intercom_conversation_id\n            , ecrb.priority_level\n            -- 1. SLA score = (1 min + waiting_time in min) / (1 min + SLA in min)\n            , CASE\n                WHEN ecrb.priority_level = 'SLA[5min]'\n                    THEN (:sync_sla_score_boost) * (3 + count_business_minutes_between(ecrb.time_reference_for_priority_score, now()))  / (5 - 2)\n                WHEN ecrb.priority_level = 'SLA[30min]'\n                    THEN (3 + count_business_minutes_between(ecrb.time_reference_for_priority_score, now()))  / (30 - 2)\n                ELSE --  priority_level = 'SLA[12h]'\n                        (3 + count_business_minutes_between(ecrb.time_reference_for_priority_score, now()))  / (12 * 60 - 2)\n                END AS sla_score\n            , CASE\n                WHEN ecrb.priority_level = 'SLA[5min]'\n                    THEN count_business_minutes_between(ecrb.time_reference_for_priority_score, now()) >= 5\n                WHEN ecrb.priority_level = 'SLA[30min]'\n                    THEN count_business_minutes_between(ecrb.time_reference_for_priority_score, now()) >= 30\n                ELSE\n                    count_business_minutes_between(ecrb.time_reference_for_priority_score, now()) >= 12 * 60\n                END AS sla_breached\n            -- 2. workforce level matching score\n            , distance_workforce_level_admin_conversation\n            -- the workforce_level_score_boost is here to give a higher assignment score when the level of the conversation matches the level of the admin\n            -- workforce_level_matching_score doesn't matter the escalation reviews of automated answers\n            , CASE\n                WHEN distance_workforce_level_admin_conversation = 0 THEN (:workforce_level_score_boost)\n                WHEN ecrb.reserved_for_review THEN (:workforce_level_score_boost)\n                WHEN distance_workforce_level_admin_conversation < 0 THEN NULL\n                ELSE 1.0 / (distance_workforce_level_admin_conversation + 1)\n                END AS workforce_level_matching_score\n            -- 3. parsing of classification result (to be used later for the expertise matching score computation)\n            , CASE WHEN jsonb_typeof(COALESCE(crcd.raw_prediction, crcd2.raw_prediction)) = 'array'\n                THEN COALESCE(crcd.raw_prediction, crcd2.raw_prediction)\n                ELSE array_to_json(array_fill(1.0 / jsonb_array_length(cls.classes) , ARRAY[jsonb_array_length(cls.classes)]))::jsonb\n                END AS predicted_probabilities\n            , CASE WHEN jsonb_typeof(COALESCE(crcd.raw_prediction_classes, crcd2.raw_prediction_classes)) = 'array'\n                THEN COALESCE(crcd.raw_prediction_classes, crcd2.raw_prediction_classes)\n                ELSE cls.classes\n                END AS predicted_classes\n            , ecrb.inbox_id\n            , ss.intercom_inbox_name as inbox_name\n            , ecrb.last_inbox_assignment_by\n            , ecrb.uce_member_matching_score\n            , ecrb.time_reference_for_priority_score\n            , ecrb.reserved_for_external_agent_platform\n            , ecrb.conversation_workforce_level\n            , ecrb.reserved_for_review\n            , ecrb.bypassed_classifier\n            , ecrb.marrakech_scope_fit\n        FROM eligible_contact_request_backlog_with_priority_level ecrb\n        INNER JOIN support.support_specialization ss ON ss.intercom_inbox_id = ecrb.inbox_id AND ss.is_used_by_assigner AND ss.intercom_workspace_id = (:workspace_id) -- only consider inboxes recognized by the assigner\n        LEFT JOIN contact_request_context_digest crcd ON crcd.contact_request_id = ecrb.contact_request_id\n        LEFT JOIN contact_request_context_digest crcd2 ON crcd.original_contact_request_id = crcd2.contact_request_id  -- inherit context from parent convo when relevant\n        CROSS JOIN classifier_classes cls\n        -- we want to prevent assignments of tickets not yet classified, but don't want to wait more than 30 seconds for sync and 2 minutes for the rest\n        WHERE jsonb_typeof(COALESCE(crcd.raw_prediction, crcd2.raw_prediction)) = 'array'\n            OR CASE\n                    WHEN ecrb.priority_level = 'SLA[5min]' THEN COALESCE(ecrb.started_at < now() at time zone 'utc' - '30 seconds'::interval, TRUE)\n                    ELSE COALESCE(ecrb.started_at < now() at time zone 'utc' - '2 minutes'::interval, TRUE)\n                END\n    ),\n    -- # 2. compute expertise matching scores for all (eligible convo X UCE) pairs\n    convos_x_probabilities AS (\n        -- expand the eligible convos on all predicted classes\n        SELECT\n            s.contact_request_id\n            , s.intercom_conversation_id\n            , s.priority_level\n            , s.sla_score\n            , s.sla_breached\n            , s.conversation_workforce_level\n            , s.workforce_level_matching_score\n            , s.uce_member_matching_score\n            , s.predicted_probability\n            , s._predicted_class\n            , s.last_inbox_assignment_by\n            , s.inbox_id\n            , s.inbox_name\n            , s.time_reference_for_priority_score\n            , s.reserved_for_external_agent_platform\n            , s.reserved_for_review\n            , s.bypassed_classifier\n            , s._predicted_class AS predicted_class\n            , s.marrakech_scope_fit\n        FROM (\n            SELECT\n                contact_request_id\n                , intercom_conversation_id\n                , priority_level\n                , sla_score\n                , sla_breached\n                , conversation_workforce_level\n                , workforce_level_matching_score\n                , uce_member_matching_score\n                , jsonb_array_elements(predicted_probabilities)::numeric AS predicted_probability\n                , trim(jsonb_array_elements(predicted_classes)::text, '\"') AS _predicted_class\n                , last_inbox_assignment_by\n                , inbox_id\n                , inbox_name\n                , time_reference_for_priority_score\n                , reserved_for_external_agent_platform\n                , reserved_for_review\n                , bypassed_classifier\n                , marrakech_scope_fit\n            FROM eligible_contact_request_backlog_with_context\n        ) s\n        UNION ALL\n        -- expand for classes unknown by the classifier as well\n        SELECT\n            contact_request_id\n            , intercom_conversation_id\n            , priority_level\n            , sla_score\n            , sla_breached\n            , conversation_workforce_level\n            , workforce_level_matching_score\n            , uce_member_matching_score\n            , 0 AS predicted_probability\n            , t.inbox_unknown_by_classifier AS _predicted_class\n            , last_inbox_assignment_by\n            , inbox_id\n            , inbox_name\n            , time_reference_for_priority_score\n            , reserved_for_external_agent_platform\n            , reserved_for_review\n            , bypassed_classifier\n            , t.inbox_unknown_by_classifier AS predicted_class\n            , marrakech_scope_fit\n        FROM eligible_contact_request_backlog_with_context\n        CROSS JOIN (\n                SELECT intercom_inbox_name AS inbox_unknown_by_classifier FROM support.support_specialization\n                EXCEPT\n                SELECT JSONB_ARRAY_ELEMENTS_TEXT(classes) FROM classifier_classes\n        ) t\n    ),\n    -- Calibration pass 1: Compute min/max across ALL agents for calibration (lightweight aggregation)\n    calibration_min_max_per_agent AS (\n        SELECT\n            sa.support_agent_id\n            , cxp.contact_request_id\n            , sum((CASE\n                    WHEN (cxp.last_inbox_assignment_by IN ('manual', 'intercom') OR cxp.bypassed_classifier OR NOT cxp.inbox_name = ANY(cls.classes_array))\n                        AND cxp.inbox_id = ss.intercom_inbox_id THEN 1.0\n                    WHEN (cxp.last_inbox_assignment_by IN ('manual', 'intercom') OR cxp.bypassed_classifier OR NOT cxp.inbox_name = ANY(cls.classes_array))\n                        AND cxp.inbox_id != ss.intercom_inbox_id THEN 0.0\n                    ELSE cxp.predicted_probability::numeric\n                END)\n                * COALESCE(m.score, 0)) AS raw_expertise_matching_score\n        FROM convos_x_probabilities cxp\n        JOIN support.support_specialization ss ON cxp.predicted_class = ss.intercom_inbox_name\n        CROSS JOIN support_agents_for_computation sa\n        LEFT JOIN uce_specialisations_mapping m ON m.intercom_inbox_id = ss.intercom_inbox_id AND m.intercom_admin_id = sa.intercom_admin_id\n        CROSS JOIN classifier_classes_as_array cls\n        WHERE\n            CASE\n                WHEN NOT cxp.reserved_for_review THEN sa.level - cxp.conversation_workforce_level >= 0\n                ELSE TRUE\n            END\n            AND CASE\n                    WHEN cxp.reserved_for_external_agent_platform IS NOT NULL\n                    THEN UPPER(sa.platform) = UPPER(cxp.reserved_for_external_agent_platform)\n                    ELSE TRUE\n            END\n        GROUP BY sa.support_agent_id, cxp.contact_request_id\n    ),\n    min_max_expertise_matching_score_per_convo AS (\n        SELECT\n            contact_request_id\n            , min(power(raw_expertise_matching_score, (:power_weight_a))) AS min_ems\n            , max(power(raw_expertise_matching_score, (:power_weight_a))) AS max_ems\n        FROM calibration_min_max_per_agent\n        GROUP BY contact_request_id\n    ),\n    -- calibration pass 2: Compute detailed scores ONLY for the requesting agent which is less costly\n    eligible_convos_with_expertise_matching_score_per_uce AS (\n        SELECT\n            sa.intercom_admin_id\n            , sa.support_agent_id\n            , cxp.intercom_conversation_id\n            , cxp.contact_request_id\n            , cxp.inbox_id\n            , cxp.inbox_name\n            , cxp.priority_level\n            , cxp.workforce_level_matching_score\n            , cxp.sla_score\n            , cxp.sla_breached\n            , cxp.uce_member_matching_score\n            , cxp.time_reference_for_priority_score\n            , cxp.reserved_for_external_agent_platform\n            , cxp.reserved_for_review\n            , sum((CASE\n                    WHEN (cxp.last_inbox_assignment_by IN ('manual', 'intercom') OR cxp.bypassed_classifier OR NOT cxp.inbox_name = ANY(cls.classes_array))\n                        AND cxp.inbox_id = ss.intercom_inbox_id THEN 1.0\n                    WHEN (cxp.last_inbox_assignment_by IN ('manual', 'intercom') OR cxp.bypassed_classifier OR NOT cxp.inbox_name = ANY(cls.classes_array))\n                        AND cxp.inbox_id != ss.intercom_inbox_id THEN 0.0\n                    ELSE cxp.predicted_probability::numeric\n                END)\n                * COALESCE(m.score, 0)) AS raw_expertise_matching_score\n            -- L1 & L2 agents have an extra boost on priority_level for sync conversations\n            , case when cxp.priority_level = 'SLA[5min]' and sa.level < 3 then 2 else 0 end as additional_power_weight_c\n            -- when it's a ticket which could NOT be handled by Marrakech, boost it if the requesting support agent is external (and not Marrakech based)\n            , CASE WHEN cxp.marrakech_scope_fit = FALSE AND NOT sa.has_role_uce AND NOT sa.is_marrakech_agent\n                THEN (:marrakech_scope_protection_boost)\n                ELSE 1\n                END AS marrakech_scope_protection_boost\n        FROM convos_x_probabilities cxp\n        JOIN support.support_specialization ss ON cxp.predicted_class = ss.intercom_inbox_name\n        CROSS JOIN support_agents_for_computation sa\n        LEFT JOIN uce_specialisations_mapping m ON m.intercom_inbox_id = ss.intercom_inbox_id AND m.intercom_admin_id = sa.intercom_admin_id\n        CROSS JOIN classifier_classes_as_array cls\n        -- additional exclusion conditions for both the care expert asking for the assignment and the calibration cohort\n        WHERE\n            sa.support_agent_id = (:support_agent_id)::uuid  -- ONLY requesting agent\n            AND CASE\n                WHEN NOT cxp.reserved_for_review THEN sa.level - cxp.conversation_workforce_level >= 0\n                ELSE TRUE\n            END\n            -- handle conversations reserved for specific external agent platforms\n            AND CASE\n                    WHEN cxp.reserved_for_external_agent_platform IS NOT NULL\n                    THEN UPPER(sa.platform) = UPPER(cxp.reserved_for_external_agent_platform)\n                    ELSE TRUE\n            END\n        GROUP BY sa.intercom_admin_id, sa.support_agent_id, cxp.contact_request_id, cxp.inbox_id, cxp.inbox_name, cxp.priority_level, cxp.workforce_level_matching_score, cxp.sla_score, cxp.sla_breached, cxp.uce_member_matching_score,\n    cxp.time_reference_for_priority_score, cxp.reserved_for_external_agent_platform, cxp.reserved_for_review, cxp.intercom_conversation_id, sa.level, cxp.marrakech_scope_fit, marrakech_scope_protection_boost\n    ),\n    eligible_convos_with_scores AS (\n        SELECT\n            ec.contact_request_id\n            , ec.intercom_conversation_id\n            , ec.inbox_id\n            , ec.inbox_name\n            , ec.time_reference_for_priority_score\n            -- normalization of the expertise matching score (distributed over [20%, 100%])\n            , CASE WHEN max_ems > min_ems\n                    THEN 0.2 + 0.8 * (power(raw_expertise_matching_score, (:power_weight_a)) - min_ems) / (max_ems - min_ems)\n                ELSE 1\n                END AS expertise_matching_score\n            , POWER(sla_score, (:power_weight_c) + ec.additional_power_weight_c) AS priority_score\n            , POWER(workforce_level_matching_score, (:power_weight_d)) AS workforce_level_matching_score\n            , ec.marrakech_scope_protection_boost\n            , uce_member_matching_score\n            , COALESCE(\n                    CASE\n                        WHEN max_ems > min_ems\n                        THEN 0.2 + 0.8 * (power(raw_expertise_matching_score, (:power_weight_a)) - min_ems) / (max_ems - min_ems)\n                        ELSE 1\n                    END\n                    * POWER(sla_score, (:power_weight_c) + ec.additional_power_weight_c)\n                    * POWER(workforce_level_matching_score, (:power_weight_d))\n                    * uce_member_matching_score\n                    * marrakech_scope_protection_boost\n                , 0) AS assignment_score\n            , raw_expertise_matching_score\n            , sla_score\n            , sla_breached\n            , MAX(POWER(sla_score, (:power_weight_c) + ec.additional_power_weight_c)) OVER () AS max_priority_score\n            , priority_level\n            , MAX(POWER(sla_score, (:power_weight_c) + ec.additional_power_weight_c)) OVER (PARTITION BY priority_level) AS max_priority_score_per_priority_level\n            , m.max_ems\n            , ec.reserved_for_external_agent_platform\n            , ec.reserved_for_review\n            , null as n_rejections\n            , ec.priority_level = 'SLA[5min]' as is_sync_conversation\n            , ec.additional_power_weight_c\n        FROM eligible_convos_with_expertise_matching_score_per_uce ec\n        JOIN min_max_expertise_matching_score_per_convo m USING(contact_request_id)\n        LEFT JOIN support.contact_request_assignment_event crae ON crae.contact_request_id = ec.contact_request_id AND crae.action = 'rejected' AND crae.intercom_admin_id = ec.intercom_admin_id\n        WHERE\n            crae.contact_request_id IS NULL\n            AND ec.raw_expertise_matching_score > 0  -- exclude conversations with negative expertise matching score for current UCE\n    ),\n    backlog_stats AS MATERIALIZED (\n        SELECT\n            count(*) as n_assignable_convos\n            , count(*) FILTER (WHERE priority_level = 'SLA[12h]') as n_assignable_async\n            , count(*) FILTER (WHERE priority_level = 'SLA[5min]') as n_assignable_sync\n            , count(*) FILTER (WHERE priority_level = 'SLA[30min]') as n_assignable_callback\n            , count(*) FILTER (WHERE priority_level = 'SLA[12h]' AND sla_breached) as n_assignable_async_sla_breached\n            , count(*) FILTER (WHERE priority_level = 'SLA[5min]' AND sla_breached) as n_assignable_sync_sla_breached\n            , count(*) FILTER (WHERE priority_level = 'SLA[30min]' AND sla_breached) as n_assignable_callback_sla_breached\n            , percentile_cont(0.9) WITHIN GROUP (ORDER BY CASE WHEN priority_level = 'SLA[12h]' THEN power(sla_score, (:power_weight_c) + additional_power_weight_c) END) AS q90_priority_score_sla_12h\n            , percentile_cont(0.9) WITHIN GROUP (ORDER BY CASE WHEN priority_level = 'SLA[5min]' THEN power(sla_score, (:power_weight_c) + additional_power_weight_c) END) AS q90_priority_score_sla_5m\n            , percentile_cont(0.9) WITHIN GROUP (ORDER BY CASE WHEN priority_level = 'SLA[30min]' THEN power(sla_score, (:power_weight_c) + additional_power_weight_c) END) AS q90_priority_score_sla_30m\n            , percentile_cont(0.9) WITHIN GROUP (ORDER BY CASE WHEN priority_level = 'SLA[12h]' THEN expertise_matching_score END) AS q90_expertise_score_sla_12h\n            , percentile_cont(0.9) WITHIN GROUP (ORDER BY CASE WHEN priority_level = 'SLA[5min]' THEN expertise_matching_score END) AS q90_expertise_score_sla_5m\n            , percentile_cont(0.9) WITHIN GROUP (ORDER BY CASE WHEN priority_level = 'SLA[30min]' THEN expertise_matching_score END) AS q90_expertise_score_sla_30m\n        FROM eligible_convos_with_scores\n    )\n    SELECT\n        *\n        , 1.0 AS shift_matching_score\n    FROM eligible_convos_with_scores\n    CROSS JOIN backlog_stats stats\n    ORDER BY assignment_score DESC NULLS LAST, time_reference_for_priority_score\n    LIMIT 10;\n    "

FR_RANKED_SQL_QUERY module-attribute

FR_RANKED_SQL_QUERY = "\n    WITH recalibrated_support_agents AS (\n        SELECT\n            sa.id\n        FROM support.assigner_recalibration ar\n        JOIN support.support_agent sa ON ar.alan_email = sa.alan_email\n        JOIN support.support_agent_workspace_affectation sawa ON sa.id = sawa.support_agent_id AND ar.intercom_workspace_id = sawa.intercom_workspace_id\n        WHERE ar.intercom_workspace_id = (:workspace_id)\n    ),\n    support_agents_for_computation AS (\n        SELECT\n            sa.id as support_agent_id\n            , sa.intercom_admin_id\n            , sawa.level\n            , sawa.intercom_workspace_id\n            -- Roles are now stored in an array, so we check for the presence of a role in the array.\n            , 'eligible_for_sync' = ANY(sawa.roles) as is_eligible_for_sync\n            , CASE WHEN (NOW() AT TIME ZONE 'Europe/Paris')::TIME BETWEEN '09:00:00' AND '19:00:00'\n                THEN 'eligible_for_async' = ANY(sawa.roles)\n                ELSE True\n                END as is_eligible_for_async\n            , sa.platform_name as platform\n            , NOT sa.is_external_admin AS has_role_uce\n            , CASE WHEN (NOW() AT TIME ZONE 'Europe/Paris')::TIME BETWEEN '09:00:00' AND '19:00:00'\n                THEN 'eligible_for_callback' = ANY(sawa.roles)\n                ELSE False\n                END as has_role_callback\n            , 'automated_answer_reviewer' = ANY(sawa.roles) as has_reviewer_role\n            , 'en' = ANY(sa.spoken_languages) as speaks_english\n            , sa.work_location != 'fr' as is_offshore\n            , CASE WHEN sa.work_location != 'fr' AND sa.platform_name = 'Webhelp'\n                THEN TRUE\n                ELSE FALSE\n                END AS is_marrakech_agent\n        FROM support.support_agent as sa\n        JOIN support.support_agent_workspace_affectation sawa ON sa.id = sawa.support_agent_id AND sawa.intercom_workspace_id = (:workspace_id)\n        WHERE (sa.id = ANY((select array(SELECT id FROM recalibrated_support_agents))::uuid[])\n            OR sa.id = (:support_agent_id))  -- the admin requesting an assignment\n    ),\n    uce_specialisations_mapping AS (\n        SELECT sa.intercom_admin_id\n            , ss.intercom_inbox_id\n            , sasms.score\n        FROM support_agents_for_computation sa\n        LEFT JOIN support.support_agent_workspace_affectation sawa ON sawa.support_agent_id = sa.support_agent_id\n        LEFT JOIN support.support_agent_spe_matching_score sasms ON sasms.support_agent_workspace_affectation_id = sawa.id\n        LEFT JOIN support.support_specialization ss ON ss.id = sasms.support_specialization_id\n        WHERE sasms.score != 0\n    ),\n    prefiltered_contact_request_backlog AS (\n        SELECT\n            DISTINCT ON (cr.intercom_conversation_id)\n            cr.id as contact_request_id\n            , cr.intercom_conversation_id\n            , CASE\n                WHEN cris.assigned_intercom_inbox_id IS NULL AND cris.assigned_intercom_admin_id IS NULL AND (:default_inbox_id) IS NOT NULL\n                THEN (:default_inbox_id) -- defaults to 'Care team' inbox id\n                ELSE cris.assigned_intercom_inbox_id\n                END as inbox_id\n            , cris.last_inbox_assignment_by\n            , cris.waiting_since\n            , greatest(cris.waiting_since, cris.last_admin_reply_at) AS time_reference_for_priority_score\n            , cris.last_admin_reply_at\n            , cram.recommended_admin_id as recommended_admin_id\n            , cris.started_at\n            , COALESCE(cram.reserved_for_automated_answers_review, FALSE) as reserved_for_review\n            , cris.state\n            , cris.assigned_intercom_admin_id as admin_id\n            , cr.created_at\n            -- using tags to compute properties of the conversations\n            , SUBSTRING(tags.platform_tag, 'ROLE\\[(.*)\\]') AS reserved_for_external_agent_platform\n            -- priority level is defined by the sync/async nature of the convo but can be overwritten during a handover\n            , CASE\n                WHEN tags.sla_tag = 'SLA[12h]' THEN 'SLA[12h]'\n                WHEN tags.sla_tag = 'SLA[5min]' THEN 'SLA[5min]'\n                WHEN tags.sla_tag = 'SLA[30min]' OR tags.callback_tag = 'ROLE[CALLBACK]' THEN 'SLA[30min]'\n                -- if not sync tagged or if one of the hotline source type → mark async.\n                WHEN tags.type_tag != 'SYNC' or cr.source_type in ('immediate_callback_request', 'hotline_request')THEN 'SLA[12h]'\n                ELSE 'SLA[5min]'\n                END AS priority_level\n            -- workforce level of the conversation\n            , CASE\n                WHEN tags.workforce_level_tag = 'ROLE[LEVEL3]' OR tags.uce_reserved_tag IS NOT NULL THEN 3\n                WHEN tags.workforce_level_tag = 'ROLE[LEVEL2]' THEN 2\n                ELSE 1\n                END AS conversation_workforce_level\n            , tags.*\n        FROM support.contact_request cr\n        JOIN support.contact_request_intercom_state cris ON cr.id = cris.contact_request_id\n        LEFT JOIN support.contact_request_assignment_metadata cram ON cr.id = cram.contact_request_id\n        LEFT JOIN support.contact_request_tag crt ON cr.id = crt.contact_request_id -- to define priority level\n        CROSS JOIN LATERAL (\n            SELECT\n                MAX(CASE WHEN crt.name IN ('ROLE[UCE]', 'COMPLEX CLAIM', 'SENSITIVE') THEN crt.name END) AS uce_reserved_tag\n                , MAX(CASE WHEN crt.name = 'ROLE[CALLBACK]' THEN crt.name END) AS callback_tag\n                , MAX(CASE WHEN crt.name IN ('ROLE[WEBHELP]', 'ROLE[ONEPILOT]') THEN crt.name END) AS platform_tag\n                , MAX(CASE WHEN crt.name = 'ROLE[ENGLISH]' THEN crt.name END) AS english_tag\n                , MAX(CASE WHEN crt.name = 'ROLE[GEORESTRICTED]' THEN crt.name END) AS geo_tag\n                , MAX(CASE WHEN crt.name IN ('ROLE[LEVEL1]','ROLE[LEVEL2]','ROLE[LEVEL3]') THEN crt.name END) AS workforce_level_tag\n                , MAX(CASE WHEN crt.name IN ('bypass-assigner', 'Abusive Conversation') THEN crt.name END) AS bypass_tag\n                , MAX(CASE WHEN crt.name IN ('SLA[12h]', 'SLA[5min]', 'SLA[30min]') THEN crt.name END) as sla_tag\n                , MAX(CASE WHEN crt.name IN ('SYNC', 'ASYNC') THEN crt.name END) as type_tag\n                , MAX(CASE WHEN crt.name IN ('bypass-classification') THEN crt.name END) AS classification_bypass_tag\n            FROM support.contact_request_tag crt\n            WHERE crt.contact_request_id = cr.id\n        ) tags\n        WHERE\n            cr.intercom_workspace_id = (:workspace_id)\n            AND cris.state = 'open'\n            AND (cris.assigned_intercom_admin_id IS NULL OR cris.assigned_intercom_admin_id = '') -- testing for empty string for records updated via flask admin\n            AND cr.created_at > '2021-11-26' -- until data is cleared in prod\n            AND tags.bypass_tag IS NULL -- exclude convos explicitly flagged as bypassing the Assigner\n        ORDER BY cr.intercom_conversation_id, cr.id, crt.notified_at DESC\n    ),\n    eligible_contact_request_backlog_with_priority_level AS (\n        SELECT\n            pcrb.contact_request_id\n            , pcrb.intercom_conversation_id\n            , pcrb.inbox_id\n            , pcrb.last_inbox_assignment_by\n            , pcrb.time_reference_for_priority_score\n            , pcrb.priority_level\n            , pcrb.conversation_workforce_level\n            , sa.level - pcrb.conversation_workforce_level AS distance_workforce_level_admin_conversation\n            , CASE WHEN pcrb.recommended_admin_id = sa.intercom_admin_id\n                THEN (:member_matching_score_boost)\n                ELSE 1\n                END AS uce_member_matching_score\n            , pcrb.started_at\n            , pcrb.reserved_for_external_agent_platform\n            , pcrb.reserved_for_review\n            , pcrb.classification_bypass_tag IS NOT NULL as bypassed_classifier\n            -- marrakech scope fit is a boolean flag to indicate if the conversation is a good fit for a marrakech agent\n            -- basically an L1 convo, not georestricted, not english, not uce reserved, not reserved for review\n            , CASE  WHEN pcrb.callback_tag IS NULL\n                    AND pcrb.conversation_workforce_level = 1\n                    AND pcrb.geo_tag IS NULL\n                    AND pcrb.english_tag IS NULL\n                    AND pcrb.uce_reserved_tag IS NULL\n                    AND NOT pcrb.reserved_for_review\n                THEN TRUE\n                ELSE FALSE\n                END AS marrakech_scope_fit\n        FROM prefiltered_contact_request_backlog pcrb\n        CROSS JOIN support_agents_for_computation sa\n        WHERE sa.support_agent_id = (:support_agent_id) -- join only on the admin requesting an assignment\n            -- reduce scope for agents depending on their sync/async eligibility configuration\n            AND (\n                -- Admin is ELIGIBLE_FOR_SYNC and conversation is NOT ASYNC and NOT CALLBACK\n                (COALESCE(sa.is_eligible_for_sync, True) AND pcrb.priority_level = 'SLA[5min]' AND pcrb.callback_tag IS NULL)\n                OR\n                -- Admin is ELIGIBLE_FOR_ASYNC and conversation is NOT SLA[5min] and NOT CALLBACK\n                (COALESCE(sa.is_eligible_for_async, True) AND pcrb.priority_level != 'SLA[5min]' AND pcrb.callback_tag IS NULL)\n                OR\n                -- Admin has CALLBACK role and conversation is CALLBACK\n                (COALESCE(sa.has_role_callback, True) AND pcrb.callback_tag IS NOT NULL)\n            )\n            -- assign automated answer reviews for reviewers\n            AND (COALESCE(sa.has_reviewer_role, False) OR pcrb.reserved_for_review IS NOT TRUE)\n            -- reduce scope for agents not speaking english\n            AND (COALESCE(sa.speaks_english, True) OR pcrb.english_tag IS NULL)\n            -- reduce scope of georestricted convos for offshore agents\n            AND (COALESCE(NOT sa.is_offshore, False) OR pcrb.geo_tag IS NULL)\n            AND CASE\n                    WHEN NOT pcrb.reserved_for_review THEN sa.level - pcrb.conversation_workforce_level >= 0\n                    ELSE TRUE\n                END\n                -- handle conversations reserved for specific external agent platforms\n                AND CASE\n                        WHEN pcrb.reserved_for_external_agent_platform IS NOT NULL\n                        THEN UPPER(sa.platform) = UPPER(pcrb.reserved_for_external_agent_platform)\n                        ELSE TRUE\n                END\n    ),\n\n    contact_request_context_digest AS (\n        SELECT\n            cr.id as contact_request_id\n            , cr.duplicated_from_id as original_contact_request_id\n            , COALESCE(cr.classification_result->'raw_prediction_classes', cr.classification_result->'prediction'->'raw_prediction_classes' ) as raw_prediction_classes\n            , COALESCE(cr.classification_result->'raw_prediction', cr.classification_result->'prediction'->'raw_prediction') as raw_prediction\n            , created_at\n        FROM support.contact_request cr\n        INNER JOIN eligible_contact_request_backlog_with_priority_level e ON e.contact_request_id = cr.id\n        WHERE cr.intercom_workspace_id = (:workspace_id)\n    ),\n    classifier_classes AS (\n        SELECT\n            raw_prediction_classes AS classes\n        FROM contact_request_context_digest\n        WHERE jsonb_typeof(raw_prediction) = 'array'\n        ORDER BY created_at DESC\n        LIMIT 1\n    ),\n    classifier_classes_as_array AS (\n        SELECT ARRAY_AGG(cls) classes_array\n        FROM classifier_classes, JSONB_ARRAY_ELEMENTS_TEXT(classifier_classes.classes) cls\n    ),\n\n    -- # 1. define the eligible backlog for the UCE requesting an assignment\n    eligible_contact_request_backlog_with_context AS (\n        SELECT\n            ecrb.contact_request_id\n            , ecrb.intercom_conversation_id\n            , ecrb.priority_level\n            -- 1. SLA score = (1 min + waiting_time in min) / (1 min + SLA in min)\n            , CASE\n                WHEN ecrb.priority_level = 'SLA[5min]'\n                    THEN (:sync_sla_score_boost) * (3 + count_business_minutes_between(ecrb.time_reference_for_priority_score, now()))  / (5 - 2)\n                WHEN ecrb.priority_level = 'SLA[30min]'\n                    THEN (3 + count_business_minutes_between(ecrb.time_reference_for_priority_score, now()))  / (30 - 2)\n                ELSE --  priority_level = 'SLA[12h]'\n                        (3 + count_business_minutes_between(ecrb.time_reference_for_priority_score, now()))  / (12 * 60 - 2)\n                END AS sla_score\n            , CASE\n                WHEN ecrb.priority_level = 'SLA[5min]'\n                    THEN count_business_minutes_between(ecrb.time_reference_for_priority_score, now()) >= 5\n                WHEN ecrb.priority_level = 'SLA[30min]'\n                    THEN count_business_minutes_between(ecrb.time_reference_for_priority_score, now()) >= 30\n                ELSE\n                    count_business_minutes_between(ecrb.time_reference_for_priority_score, now()) >= 12 * 60\n                END AS sla_breached\n            -- 2. workforce level matching score\n            , distance_workforce_level_admin_conversation\n            -- the workforce_level_score_boost is here to give a higher assignment score when the level of the conversation matches the level of the admin\n            -- workforce_level_matching_score doesn't matter the escalation reviews of automated answers\n            , CASE\n                WHEN distance_workforce_level_admin_conversation = 0 THEN (:workforce_level_score_boost)\n                WHEN ecrb.reserved_for_review THEN (:workforce_level_score_boost)\n                WHEN distance_workforce_level_admin_conversation < 0 THEN NULL\n                ELSE 1.0 / (distance_workforce_level_admin_conversation + 1)\n                END AS workforce_level_matching_score\n            -- 3. parsing of classification result (to be used later for the expertise matching score computation)\n            , CASE WHEN jsonb_typeof(COALESCE(crcd.raw_prediction, crcd2.raw_prediction)) = 'array'\n                THEN COALESCE(crcd.raw_prediction, crcd2.raw_prediction)\n                ELSE array_to_json(array_fill(1.0 / jsonb_array_length(cls.classes) , ARRAY[jsonb_array_length(cls.classes)]))::jsonb\n                END AS predicted_probabilities\n            , CASE WHEN jsonb_typeof(COALESCE(crcd.raw_prediction_classes, crcd2.raw_prediction_classes)) = 'array'\n                THEN COALESCE(crcd.raw_prediction_classes, crcd2.raw_prediction_classes)\n                ELSE cls.classes\n                END AS predicted_classes\n            , ecrb.inbox_id\n            , ss.intercom_inbox_name as inbox_name\n            , ecrb.last_inbox_assignment_by\n            , ecrb.uce_member_matching_score\n            , ecrb.time_reference_for_priority_score\n            , ecrb.reserved_for_external_agent_platform\n            , ecrb.conversation_workforce_level\n            , ecrb.reserved_for_review\n            , ecrb.bypassed_classifier\n            , ecrb.marrakech_scope_fit\n        FROM eligible_contact_request_backlog_with_priority_level ecrb\n        INNER JOIN support.support_specialization ss ON ss.intercom_inbox_id = ecrb.inbox_id AND ss.is_used_by_assigner AND ss.intercom_workspace_id = (:workspace_id) -- only consider inboxes recognized by the assigner\n        LEFT JOIN contact_request_context_digest crcd ON crcd.contact_request_id = ecrb.contact_request_id\n        LEFT JOIN contact_request_context_digest crcd2 ON crcd.original_contact_request_id = crcd2.contact_request_id  -- inherit context from parent convo when relevant\n        CROSS JOIN classifier_classes cls\n        -- we want to prevent assignments of tickets not yet classified, but don't want to wait more than 30 seconds for sync and 2 minutes for the rest\n        WHERE jsonb_typeof(COALESCE(crcd.raw_prediction, crcd2.raw_prediction)) = 'array'\n            OR CASE\n                    WHEN ecrb.priority_level = 'SLA[5min]' THEN COALESCE(ecrb.started_at < now() at time zone 'utc' - '30 seconds'::interval, TRUE)\n                    ELSE COALESCE(ecrb.started_at < now() at time zone 'utc' - '2 minutes'::interval, TRUE)\n                END\n    ),\n    -- # 2. compute expertise matching scores for all (eligible convo X UCE) pairs\n    convos_x_probabilities AS (\n        -- expand the eligible convos on all predicted classes\n        SELECT\n            s.contact_request_id\n            , s.intercom_conversation_id\n            , s.priority_level\n            , s.sla_score\n            , s.sla_breached\n            , s.conversation_workforce_level\n            , s.workforce_level_matching_score\n            , s.uce_member_matching_score\n            , s.predicted_probability\n            , s._predicted_class\n            , s.last_inbox_assignment_by\n            , s.inbox_id\n            , s.inbox_name\n            , s.time_reference_for_priority_score\n            , s.reserved_for_external_agent_platform\n            , s.reserved_for_review\n            , s.bypassed_classifier\n            , s._predicted_class AS predicted_class\n            , s.marrakech_scope_fit\n        FROM (\n            SELECT\n                contact_request_id\n                , intercom_conversation_id\n                , priority_level\n                , sla_score\n                , sla_breached\n                , conversation_workforce_level\n                , workforce_level_matching_score\n                , uce_member_matching_score\n                , jsonb_array_elements(predicted_probabilities)::numeric AS predicted_probability\n                , trim(jsonb_array_elements(predicted_classes)::text, '\"') AS _predicted_class\n                , last_inbox_assignment_by\n                , inbox_id\n                , inbox_name\n                , time_reference_for_priority_score\n                , reserved_for_external_agent_platform\n                , reserved_for_review\n                , bypassed_classifier\n                , marrakech_scope_fit\n            FROM eligible_contact_request_backlog_with_context\n        ) s\n        UNION ALL\n        -- expand for classes unknown by the classifier as well\n        SELECT\n            contact_request_id\n            , intercom_conversation_id\n            , priority_level\n            , sla_score\n            , sla_breached\n            , conversation_workforce_level\n            , workforce_level_matching_score\n            , uce_member_matching_score\n            , 0 AS predicted_probability\n            , t.inbox_unknown_by_classifier AS _predicted_class\n            , last_inbox_assignment_by\n            , inbox_id\n            , inbox_name\n            , time_reference_for_priority_score\n            , reserved_for_external_agent_platform\n            , reserved_for_review\n            , bypassed_classifier\n            , t.inbox_unknown_by_classifier AS predicted_class\n            , marrakech_scope_fit\n        FROM eligible_contact_request_backlog_with_context\n        CROSS JOIN (\n                SELECT intercom_inbox_name AS inbox_unknown_by_classifier FROM support.support_specialization\n                EXCEPT\n                SELECT JSONB_ARRAY_ELEMENTS_TEXT(classes) FROM classifier_classes\n        ) t\n    ),\n    -- Calibration pass 1: Compute min/max across ALL agents for calibration (lightweight aggregation)\n    calibration_min_max_per_agent AS (\n        SELECT\n            sa.support_agent_id\n            , cxp.contact_request_id\n            , sum((CASE\n                    WHEN (cxp.last_inbox_assignment_by IN ('manual', 'intercom') OR cxp.bypassed_classifier OR NOT cxp.inbox_name = ANY(cls.classes_array))\n                        AND cxp.inbox_id = ss.intercom_inbox_id THEN 1.0\n                    WHEN (cxp.last_inbox_assignment_by IN ('manual', 'intercom') OR cxp.bypassed_classifier OR NOT cxp.inbox_name = ANY(cls.classes_array))\n                        AND cxp.inbox_id != ss.intercom_inbox_id THEN 0.0\n                    ELSE cxp.predicted_probability::numeric\n                END)\n                * COALESCE(m.score, 0)) AS raw_expertise_matching_score\n        FROM convos_x_probabilities cxp\n        JOIN support.support_specialization ss ON cxp.predicted_class = ss.intercom_inbox_name\n        CROSS JOIN support_agents_for_computation sa\n        LEFT JOIN uce_specialisations_mapping m ON m.intercom_inbox_id = ss.intercom_inbox_id AND m.intercom_admin_id = sa.intercom_admin_id\n        CROSS JOIN classifier_classes_as_array cls\n        WHERE\n            CASE\n                WHEN NOT cxp.reserved_for_review THEN sa.level - cxp.conversation_workforce_level >= 0\n                ELSE TRUE\n            END\n            AND CASE\n                    WHEN cxp.reserved_for_external_agent_platform IS NOT NULL\n                    THEN UPPER(sa.platform) = UPPER(cxp.reserved_for_external_agent_platform)\n                    ELSE TRUE\n            END\n        GROUP BY sa.support_agent_id, cxp.contact_request_id\n    ),\n    min_max_expertise_matching_score_per_convo AS (\n        SELECT\n            contact_request_id\n            , min(power(raw_expertise_matching_score, (:power_weight_a))) AS min_ems\n            , max(power(raw_expertise_matching_score, (:power_weight_a))) AS max_ems\n        FROM calibration_min_max_per_agent\n        GROUP BY contact_request_id\n    ),\n    -- calibration pass 2: Compute detailed scores ONLY for the requesting agent which is less costly\n    eligible_convos_with_expertise_matching_score_per_uce AS (\n        SELECT\n            sa.intercom_admin_id\n            , sa.support_agent_id\n            , cxp.intercom_conversation_id\n            , cxp.contact_request_id\n            , cxp.inbox_id\n            , cxp.inbox_name\n            , cxp.priority_level\n            , cxp.workforce_level_matching_score\n            , cxp.sla_score\n            , cxp.sla_breached\n            , cxp.uce_member_matching_score\n            , cxp.time_reference_for_priority_score\n            , cxp.reserved_for_external_agent_platform\n            , cxp.reserved_for_review\n            , sum((CASE\n                    WHEN (cxp.last_inbox_assignment_by IN ('manual', 'intercom') OR cxp.bypassed_classifier OR NOT cxp.inbox_name = ANY(cls.classes_array))\n                        AND cxp.inbox_id = ss.intercom_inbox_id THEN 1.0\n                    WHEN (cxp.last_inbox_assignment_by IN ('manual', 'intercom') OR cxp.bypassed_classifier OR NOT cxp.inbox_name = ANY(cls.classes_array))\n                        AND cxp.inbox_id != ss.intercom_inbox_id THEN 0.0\n                    ELSE cxp.predicted_probability::numeric\n                END)\n                * COALESCE(m.score, 0)) AS raw_expertise_matching_score\n            -- L1 & L2 agents have an extra boost on priority_level for sync conversations\n            , case when cxp.priority_level = 'SLA[5min]' and sa.level < 3 then 2 else 0 end as additional_power_weight_c\n            -- when it's a ticket which could NOT be handled by Marrakech, boost it if the requesting support agent is external (and not Marrakech based)\n            , CASE WHEN cxp.marrakech_scope_fit = FALSE AND NOT sa.has_role_uce AND NOT sa.is_marrakech_agent\n                THEN (:marrakech_scope_protection_boost)\n                ELSE 1\n                END AS marrakech_scope_protection_boost\n        FROM convos_x_probabilities cxp\n        JOIN support.support_specialization ss ON cxp.predicted_class = ss.intercom_inbox_name\n        CROSS JOIN support_agents_for_computation sa\n        LEFT JOIN uce_specialisations_mapping m ON m.intercom_inbox_id = ss.intercom_inbox_id AND m.intercom_admin_id = sa.intercom_admin_id\n        CROSS JOIN classifier_classes_as_array cls\n        -- additional exclusion conditions for both the care expert asking for the assignment and the calibration cohort\n        WHERE\n            sa.support_agent_id = (:support_agent_id)::uuid  -- ONLY requesting agent\n            AND CASE\n                WHEN NOT cxp.reserved_for_review THEN sa.level - cxp.conversation_workforce_level >= 0\n                ELSE TRUE\n            END\n            -- handle conversations reserved for specific external agent platforms\n            AND CASE\n                    WHEN cxp.reserved_for_external_agent_platform IS NOT NULL\n                    THEN UPPER(sa.platform) = UPPER(cxp.reserved_for_external_agent_platform)\n                    ELSE TRUE\n            END\n        GROUP BY sa.intercom_admin_id, sa.support_agent_id, cxp.contact_request_id, cxp.inbox_id, cxp.inbox_name, cxp.priority_level, cxp.workforce_level_matching_score, cxp.sla_score, cxp.sla_breached, cxp.uce_member_matching_score,\n    cxp.time_reference_for_priority_score, cxp.reserved_for_external_agent_platform, cxp.reserved_for_review, cxp.intercom_conversation_id, sa.level, cxp.marrakech_scope_fit, marrakech_scope_protection_boost\n    ),\n    eligible_convos_with_scores AS (\n        SELECT\n            ec.contact_request_id\n            , ec.intercom_conversation_id\n            , ec.inbox_id\n            , ec.inbox_name\n            , ec.time_reference_for_priority_score\n            -- normalization of the expertise matching score (distributed over [20%, 100%])\n            , CASE WHEN max_ems > min_ems\n                    THEN 0.2 + 0.8 * (power(raw_expertise_matching_score, (:power_weight_a)) - min_ems) / (max_ems - min_ems)\n                ELSE 1\n                END AS expertise_matching_score\n            , POWER(sla_score, (:power_weight_c) + ec.additional_power_weight_c) AS priority_score\n            , POWER(workforce_level_matching_score, (:power_weight_d)) AS workforce_level_matching_score\n            , ec.marrakech_scope_protection_boost\n            , uce_member_matching_score\n            , COALESCE(\n                    CASE\n                        WHEN max_ems > min_ems\n                        THEN 0.2 + 0.8 * (power(raw_expertise_matching_score, (:power_weight_a)) - min_ems) / (max_ems - min_ems)\n                        ELSE 1\n                    END\n                    * POWER(sla_score, (:power_weight_c) + ec.additional_power_weight_c)\n                    * POWER(workforce_level_matching_score, (:power_weight_d))\n                    * uce_member_matching_score\n                    * marrakech_scope_protection_boost\n                , 0) AS assignment_score\n            , raw_expertise_matching_score\n            , sla_score\n            , sla_breached\n            , MAX(POWER(sla_score, (:power_weight_c) + ec.additional_power_weight_c)) OVER () AS max_priority_score\n            , priority_level\n            , MAX(POWER(sla_score, (:power_weight_c) + ec.additional_power_weight_c)) OVER (PARTITION BY priority_level) AS max_priority_score_per_priority_level\n            , m.max_ems\n            , ec.reserved_for_external_agent_platform\n            , ec.reserved_for_review\n            , null as n_rejections\n            , ec.priority_level = 'SLA[5min]' as is_sync_conversation\n            , ec.additional_power_weight_c\n        FROM eligible_convos_with_expertise_matching_score_per_uce ec\n        JOIN min_max_expertise_matching_score_per_convo m USING(contact_request_id)\n        LEFT JOIN support.contact_request_assignment_event crae ON crae.contact_request_id = ec.contact_request_id AND crae.action = 'rejected' AND crae.intercom_admin_id = ec.intercom_admin_id\n        WHERE\n            crae.contact_request_id IS NULL\n            AND ec.raw_expertise_matching_score > 0  -- exclude conversations with negative expertise matching score for current UCE\n    ),\n    backlog_stats AS MATERIALIZED (\n        SELECT\n            count(*) as n_assignable_convos\n            , count(*) FILTER (WHERE priority_level = 'SLA[12h]') as n_assignable_async\n            , count(*) FILTER (WHERE priority_level = 'SLA[5min]') as n_assignable_sync\n            , count(*) FILTER (WHERE priority_level = 'SLA[30min]') as n_assignable_callback\n            , count(*) FILTER (WHERE priority_level = 'SLA[12h]' AND sla_breached) as n_assignable_async_sla_breached\n            , count(*) FILTER (WHERE priority_level = 'SLA[5min]' AND sla_breached) as n_assignable_sync_sla_breached\n            , count(*) FILTER (WHERE priority_level = 'SLA[30min]' AND sla_breached) as n_assignable_callback_sla_breached\n            , percentile_cont(0.9) WITHIN GROUP (ORDER BY CASE WHEN priority_level = 'SLA[12h]' THEN power(sla_score, (:power_weight_c) + additional_power_weight_c) END) AS q90_priority_score_sla_12h\n            , percentile_cont(0.9) WITHIN GROUP (ORDER BY CASE WHEN priority_level = 'SLA[5min]' THEN power(sla_score, (:power_weight_c) + additional_power_weight_c) END) AS q90_priority_score_sla_5m\n            , percentile_cont(0.9) WITHIN GROUP (ORDER BY CASE WHEN priority_level = 'SLA[30min]' THEN power(sla_score, (:power_weight_c) + additional_power_weight_c) END) AS q90_priority_score_sla_30m\n            , percentile_cont(0.9) WITHIN GROUP (ORDER BY CASE WHEN priority_level = 'SLA[12h]' THEN expertise_matching_score END) AS q90_expertise_score_sla_12h\n            , percentile_cont(0.9) WITHIN GROUP (ORDER BY CASE WHEN priority_level = 'SLA[5min]' THEN expertise_matching_score END) AS q90_expertise_score_sla_5m\n            , percentile_cont(0.9) WITHIN GROUP (ORDER BY CASE WHEN priority_level = 'SLA[30min]' THEN expertise_matching_score END) AS q90_expertise_score_sla_30m\n        FROM eligible_convos_with_scores\n    )\n    SELECT\n        row_number() OVER (ORDER BY assignment_score DESC NULLS LAST, time_reference_for_priority_score) AS assignment_rank\n        , (:support_agent_id)\n        , *\n        , row_number() OVER (ORDER BY intercom_conversation_id = (:conversation_id) DESC, assignment_score DESC NULLS LAST, time_reference_for_priority_score) AS row_nb\n    FROM eligible_convos_with_scores\n    CROSS JOIN backlog_stats stats\n    ORDER BY row_nb, assignment_score DESC NULLS LAST, time_reference_for_priority_score\n    LIMIT 10;\n"

components.support.public.dependencies

SupportSnoozeDependency

Bases: ABC

This allows country-specific apps to define a class to inject dependencies to the support component for the Snooze feature.

get_legacy_cancellable_on_event_snooze_sequences abstractmethod

get_legacy_cancellable_on_event_snooze_sequences(
    cancel_on_event, force_include_legacy_ids=None
)

Pull ALL legacy sequences cancellable on event as sync data.

force_include_legacy_ids: legacy sequence IDs to include even if already cancelled (used to sync cancellation state back to global sequences that are still active).

Source code in components/support/subcomponents/snooze/protected/dependencies.py
@abstractmethod
def get_legacy_cancellable_on_event_snooze_sequences(
    self,
    cancel_on_event: str,
    force_include_legacy_ids: list[UUID] | None = None,
) -> list["SnoozeSequenceSyncData"]:
    """Pull ALL legacy sequences cancellable on event as sync data.

    force_include_legacy_ids: legacy sequence IDs to include even if already cancelled
    (used to sync cancellation state back to global sequences that are still active).
    """

get_legacy_snooze_sequences_sync_data abstractmethod

get_legacy_snooze_sequences_sync_data(
    intercom_conversation_id,
)

Pull ALL legacy sequences for a conversation as sync data (pre-sync legacy→global).

Source code in components/support/subcomponents/snooze/protected/dependencies.py
@abstractmethod
def get_legacy_snooze_sequences_sync_data(
    self,
    intercom_conversation_id: str,
) -> list["SnoozeSequenceSyncData"]:
    """Pull ALL legacy sequences for a conversation as sync data (pre-sync legacy→global)."""

get_legacy_snooze_templates_for_admin abstractmethod

get_legacy_snooze_templates_for_admin(intercom_admin_id)

Pull legacy snooze sequences marked as templates for a given admin (pre-sync for templates).

Returns empty list for countries without legacy snooze tables.

Source code in components/support/subcomponents/snooze/protected/dependencies.py
@abstractmethod
def get_legacy_snooze_templates_for_admin(
    self,
    intercom_admin_id: str,
) -> list["SnoozeSequenceSyncData"]:
    """Pull legacy snooze sequences marked as templates for a given admin (pre-sync for templates).

    Returns empty list for countries without legacy snooze tables.
    """

get_possible_cancel_on_event_metadata_for_care_event abstractmethod

get_possible_cancel_on_event_metadata_for_care_event(
    intercom_conversation_id,
)

Returns care event metadata + display data from conversation context.

Source code in components/support/subcomponents/snooze/protected/dependencies.py
@abstractmethod
def get_possible_cancel_on_event_metadata_for_care_event(
    self, intercom_conversation_id: str
) -> "list[PossibleCancelOnEventMetadata]":
    """Returns care event metadata + display data from conversation context."""

get_possible_cancel_on_event_metadata_for_teletransmission abstractmethod

get_possible_cancel_on_event_metadata_for_teletransmission(
    app_user_id,
)

Returns one entry per beneficiary on user's active policy, with TT display data.

Source code in components/support/subcomponents/snooze/protected/dependencies.py
@abstractmethod
def get_possible_cancel_on_event_metadata_for_teletransmission(
    self, app_user_id: str
) -> "list[PossibleCancelOnEventMetadata]":
    """Returns one entry per beneficiary on user's active policy, with TT display data."""

get_snooze_auto_cancel_data_for_care_event abstractmethod

get_snooze_auto_cancel_data_for_care_event(
    snooze_sequence_id, metadata
)

Returns current data for a care event (for snooze auto-cancel logic). Used for: end-of-sequence notes, automated status cancellation, metadata completion. Returns None if care event not found or feature not supported.

Source code in components/support/subcomponents/snooze/protected/dependencies.py
@abstractmethod
def get_snooze_auto_cancel_data_for_care_event(
    self, snooze_sequence_id: UUID | None, metadata: dict[str, Any]
) -> "SnoozeAutoCancelDataForCareEvent | None":
    """
    Returns current data for a care event (for snooze auto-cancel logic).
    Used for: end-of-sequence notes, automated status cancellation, metadata completion.
    Returns None if care event not found or feature not supported.
    """

get_snooze_auto_cancel_data_for_teletransmission abstractmethod

get_snooze_auto_cancel_data_for_teletransmission(
    snooze_sequence_id, metadata
)

Returns current teletransmission data (for snooze auto-cancel logic). Used for: end-of-sequence notes, automated status cancellation, metadata completion. Returns None if insurance profile not found or feature not supported.

Source code in components/support/subcomponents/snooze/protected/dependencies.py
@abstractmethod
def get_snooze_auto_cancel_data_for_teletransmission(
    self, snooze_sequence_id: UUID | None, metadata: dict[str, Any]
) -> "SnoozeAutoCancelDataForTeletransmission | None":
    """
    Returns current teletransmission data (for snooze auto-cancel logic).
    Used for: end-of-sequence notes, automated status cancellation, metadata completion.
    Returns None if insurance profile not found or feature not supported.
    """

get_user_greeting_context abstractmethod

get_user_greeting_context(user_id)

Returns greeting-related user data for snooze reply message interpolation.

Source code in components/support/subcomponents/snooze/protected/dependencies.py
@abstractmethod
def get_user_greeting_context(self, user_id: str) -> "UserGreetingContext | None":
    """Returns greeting-related user data for snooze reply message interpolation."""

sync_snooze_sequences_to_legacy abstractmethod

sync_snooze_sequences_to_legacy(
    sequences_data, commit=True
)

Push ALL global sequences for a conversation to legacy tables (post-sync global→legacy).

Source code in components/support/subcomponents/snooze/protected/dependencies.py
@abstractmethod
def sync_snooze_sequences_to_legacy(
    self,
    sequences_data: list["SnoozeSequenceSyncData"],
    commit: bool = True,
) -> None:
    """Push ALL global sequences for a conversation to legacy tables (post-sync global→legacy)."""

components.support.public.entities

ai_tooling_entities

HarryComposerContextSection dataclass

HarryComposerContextSection(
    *, title, section_type, harry_composer_context
)

Bases: BaseContextSection

Context section in the debug tool to view Harry Composer context

harry_composer_context instance-attribute
harry_composer_context
section_type class-attribute instance-attribute
section_type = HARRY_COMPOSER

HarryComposerContextSectionType

Bases: BaseContextSectionType

Defines type of context section - Harry Composer

HARRY_COMPOSER class-attribute instance-attribute
HARRY_COMPOSER = 'harry_composer'

MemberAttributesContextSection dataclass

MemberAttributesContextSection(
    *, title, section_type, member_attributes
)

Bases: BaseContextSection

Context section in the debug tool to view member attributes used in the conversation. member_attributes: JSON dump of member attributes

member_attributes instance-attribute
member_attributes
section_type class-attribute instance-attribute
section_type = MEMBER_ATTRIBUTES

MemberAttributesContextSectionType

Bases: BaseContextSectionType

Defines type of context section - member attributes

MEMBER_ATTRIBUTES class-attribute instance-attribute
MEMBER_ATTRIBUTES = 'member_attributes'

assignment_entities

ContactRequestAssignmentSource

Bases: AlanBaseEnum

backend class-attribute instance-attribute
backend = 'backend'
intercom class-attribute instance-attribute
intercom = 'intercom'
manual class-attribute instance-attribute
manual = 'manual'

channel_management_entities

CareChannel

Bases: AlanBaseEnum

All channels that are available for care

callback class-attribute instance-attribute
callback = 'callback'
chat class-attribute instance-attribute
chat = 'chat'
email class-attribute instance-attribute
email = 'email'
hotline class-attribute instance-attribute
hotline = 'hotline'
immediate_callback class-attribute instance-attribute
immediate_callback = 'immediate_callback'

ContactCountryDetails dataclass

ContactCountryDetails(
    account_ids,
    population_ids,
    is_eligible_for_hotline=False,
    is_eligible_for_one_hour_callback=False,
    is_eligible_for_two_days_callback=False,
    hotline_phone_number=None,
)

Country specific details for a user/role pair.

account_ids instance-attribute
account_ids
hotline_phone_number class-attribute instance-attribute
hotline_phone_number = None
is_eligible_for_hotline class-attribute instance-attribute
is_eligible_for_hotline = False
is_eligible_for_one_hour_callback class-attribute instance-attribute
is_eligible_for_one_hour_callback = False
is_eligible_for_two_days_callback class-attribute instance-attribute
is_eligible_for_two_days_callback = False
population_ids instance-attribute
population_ids

PhoneSupportEligibility dataclass

PhoneSupportEligibility(
    is_eligible_for_hotline=False,
    is_eligible_for_one_hour_callback=False,
    is_eligible_for_two_days_callback=False,
    hotline_phone_number=None,
    callback_phone_number=None,
)

Groups phone support eligibility information

callback_phone_number class-attribute instance-attribute
callback_phone_number = None
hotline_phone_number class-attribute instance-attribute
hotline_phone_number = None
is_eligible_for_hotline class-attribute instance-attribute
is_eligible_for_hotline = False
is_eligible_for_one_hour_callback class-attribute instance-attribute
is_eligible_for_one_hour_callback = False
is_eligible_for_two_days_callback class-attribute instance-attribute
is_eligible_for_two_days_callback = False

PopulationConfiguration dataclass

PopulationConfiguration(config)

A grouping of UserSubsetConfiguration instances, grouped by a top level setting.

config instance-attribute
config
from_config classmethod
from_config(config)

Initialize a PopulationConfiguration from a config dict.

Source code in components/support/subcomponents/channel_management/protected/entities/population_configuration.py
@classmethod
def from_config(
    cls, config: PopulationFrontendDescriptionType
) -> "PopulationConfiguration":
    """
    Initialize a PopulationConfiguration from a config dict.
    """
    return PopulationConfiguration(
        config={
            cast("str", c.get("id")): UserSubsetConfiguration.from_config(c)
            for c in config
        }
    )
is_valid
is_valid(settings)

Check if the settings are valid for the current PopulationConfiguration.

All subsets need to be valid, and at least one needs to be enabled.

Source code in components/support/subcomponents/channel_management/protected/entities/population_configuration.py
def is_valid(self, settings: list[str]) -> bool:
    """
    Check if the settings are valid for the current PopulationConfiguration.

    All subsets need to be valid, and at least one needs to be enabled.
    """
    depth_check = all(c.is_valid(settings) for c in self.config.values())
    first_level_check = any(k in settings for k in self.config.keys())

    return depth_check and first_level_check

PopulationFrontendDescriptionType module-attribute

PopulationFrontendDescriptionType = NewType(
    "PopulationFrontendDescriptionType",
    list[UserSubsetConfigurationType],
)

Frontend layout for the population configuration is driven by the following structure:

[ { "id": "disable_all_members", "description": "Disable for all members", "filtering": [ { "id": "disable_member_ani", "description": "disable ANI", }, ], }, ... ]

There may be no filtering, or multiple filtering options. If any filtering is present, the "top level" id cannot be present.

From this description, we expect the actual choice to be expressed as a simple list of string ids.

SupportAvailabilityDetails dataclass

SupportAvailabilityDetails(
    opening_status,
    phone_support_eligibility,
    active_cutoff_period=None,
)

Represents the availability of support for a given population and account.

active_cutoff_period class-attribute instance-attribute
active_cutoff_period = None
opening_status instance-attribute
opening_status
phone_support_eligibility instance-attribute
phone_support_eligibility

SupportOpeningStatus dataclass

SupportOpeningStatus(
    is_open,
    banner_message,
    closes_at,
    email_answer_by,
    chat_answer_by,
    hotline_answer_by,
    immediate_callback_answer_by,
    callback_answer_by,
)

Tells if the support is open or not. Optionally, a general (service wide) banner message can be provided.

banner_message instance-attribute
banner_message
callback_answer_by instance-attribute
callback_answer_by
chat_answer_by instance-attribute
chat_answer_by
closes_at instance-attribute
closes_at
email_answer_by instance-attribute
email_answer_by
hotline_answer_by instance-attribute
hotline_answer_by
immediate_callback_answer_by instance-attribute
immediate_callback_answer_by
is_open instance-attribute
is_open

classification_entities

ClassificationResult dataclass

ClassificationResult(
    conversation_id,
    prediction,
    prevent_assignment_reason=None,
    default_assignment_disabled=False,
    inbox_id=None,
    jtbd_id=None,
    is_assignment_to_current_inbox=False,
)
can_assign
can_assign()
Source code in components/support/internal/entities/classification_result.py
def can_assign(self) -> bool:
    return (
        self.prevent_assignment_reason is None
        and not self.default_assignment_disabled
        and self.inbox_id is not None
        and not self.is_assignment_to_current_inbox
    )
can_tag
can_tag()
Source code in components/support/internal/entities/classification_result.py
def can_tag(self) -> bool:
    return self.is_jtbd_prediction() and self.jtbd_id is not None
conversation_id instance-attribute
conversation_id
default_assignment_disabled class-attribute instance-attribute
default_assignment_disabled = False
get_classification_note
get_classification_note()
Source code in components/support/internal/entities/classification_result.py
def get_classification_note(self) -> str:
    if self.is_spe_prediction():
        return f"Classified by backend to {self.prediction.predicted_inbox} with probability {self.prediction.probability:.1%}."  # type: ignore[union-attr]
    else:
        jtbd_prediction = self.prediction.jtbd_prediction  # type: ignore[union-attr]
        return f"""
        🧙 **Tag <em>{jtbd_prediction}</em> automatiquement ajouté par notre I.A. Harry** 🧙
        Ce n'est pas le bon tag ?  Vous pouvez le changer directement depuis le Tag Helper dans l'Assistant et entraîner Harry pour la prochaine fois.
        """
inbox_id class-attribute instance-attribute
inbox_id = None
is_assignment_to_current_inbox class-attribute instance-attribute
is_assignment_to_current_inbox = False
is_jtbd_prediction
is_jtbd_prediction()
Source code in components/support/internal/entities/classification_result.py
def is_jtbd_prediction(self) -> bool:
    return isinstance(self.prediction, JTBDClassificationResult)
is_spe_prediction
is_spe_prediction()
Source code in components/support/internal/entities/classification_result.py
def is_spe_prediction(self) -> bool:
    return isinstance(self.prediction, LegacySpeClassificationResult)
jtbd_id class-attribute instance-attribute
jtbd_id = None
prediction instance-attribute
prediction
prevent_assignment_reason class-attribute instance-attribute
prevent_assignment_reason = None
to_dict
to_dict()
Source code in components/support/internal/entities/classification_result.py
def to_dict(self) -> dict[str, Any]:
    return asdict(self)

HarryClassificationResult dataclass

HarryClassificationResult(
    jtbd_classification_result,
    spe_classification_result=None,
)

Bases: DataClassJsonMixin

jtbd_classification_result instance-attribute
jtbd_classification_result
spe_classification_result class-attribute instance-attribute
spe_classification_result = None

JTBDClassificationResult dataclass

JTBDClassificationResult(
    chain_of_thought,
    top_3_jtbd_predictions,
    jtbd_prediction=None,
    predicted_inbox=None,
    raw_prediction=None,
    raw_prediction_classes=None,
)

Bases: DataClassJsonMixin

chain_of_thought instance-attribute
chain_of_thought
jtbd_prediction class-attribute instance-attribute
jtbd_prediction = None
predicted_inbox class-attribute instance-attribute
predicted_inbox = None
raw_prediction class-attribute instance-attribute
raw_prediction = None
raw_prediction_classes class-attribute instance-attribute
raw_prediction_classes = None
top_3_jtbd_predictions instance-attribute
top_3_jtbd_predictions

LegacySpeClassificationResult dataclass

LegacySpeClassificationResult(
    raw_prediction=None,
    raw_prediction_classes=None,
    predicted_inbox=None,
    probability=None,
)
predicted_inbox class-attribute instance-attribute
predicted_inbox = None
probability class-attribute instance-attribute
probability = None
raw_prediction class-attribute instance-attribute
raw_prediction = None
raw_prediction_classes class-attribute instance-attribute
raw_prediction_classes = None

SpeClassificationResult dataclass

SpeClassificationResult(
    top_4_spe_inboxes=None, chain_of_thought=None
)

Bases: DataClassJsonMixin

chain_of_thought class-attribute instance-attribute
chain_of_thought = None
top_4_spe_inboxes class-attribute instance-attribute
top_4_spe_inboxes = None

contact_country_details

ContactCountryDetails dataclass

ContactCountryDetails(
    account_ids,
    population_ids,
    is_eligible_for_hotline=False,
    is_eligible_for_one_hour_callback=False,
    is_eligible_for_two_days_callback=False,
    hotline_phone_number=None,
)

Country specific details for a user/role pair.

account_ids instance-attribute
account_ids
hotline_phone_number class-attribute instance-attribute
hotline_phone_number = None
is_eligible_for_hotline class-attribute instance-attribute
is_eligible_for_hotline = False
is_eligible_for_one_hour_callback class-attribute instance-attribute
is_eligible_for_one_hour_callback = False
is_eligible_for_two_days_callback class-attribute instance-attribute
is_eligible_for_two_days_callback = False
population_ids instance-attribute
population_ids

contact_request_entities

ContactRequestWithTagsAndIntercomStateEntity dataclass

ContactRequestWithTagsAndIntercomStateEntity(
    id,
    intercom_conversation_id,
    intercom_workspace_id,
    source_type,
    has_been_processed_as_new_conversation,
    duplicated_from_id,
    classification_result,
    app_id,
    app_user_id,
    legacy_conversation_context_id,
    legacy_conversation_context_app_id,
    language,
    source_id,
    created_at,
    updated_at,
    intercom_state,
    tags,
)

Bases: ContactRequestEntity

from_model classmethod
from_model(model)

Beware, this method calls contact_request.intercom_state and contact_request.tags, which can trigger N+1 queries. Please make sure that's okay or that you've already joined the intercom_state and tags in the query that got the contact_request.

Source code in components/support/internal/entities/contact_request_entity.py
@classmethod
def from_model(
    cls, model: "ContactRequest"
) -> "ContactRequestWithTagsAndIntercomStateEntity":
    """
    Beware, this method calls contact_request.intercom_state and contact_request.tags,
    which can trigger N+1 queries. Please make sure that's okay or that you've already joined the intercom_state and tags
    in the query that got the contact_request.
    """
    return cls(
        id=model.id,
        intercom_conversation_id=model.intercom_conversation_id,
        intercom_workspace_id=model.intercom_workspace_id,
        source_type=model.source_type,
        has_been_processed_as_new_conversation=model.has_been_processed_as_new_conversation,
        duplicated_from_id=model.duplicated_from_id,
        classification_result=model.classification_result,
        intercom_state=ContactRequestIntercomStateEntity.from_model(
            model.intercom_state
        )
        if model.intercom_state
        else ContactRequestIntercomStateEntity(
            id=None,
            contact_request_id=model.id,
            waiting_since=None,
            state=None,
            last_inbox_assignment_by=None,
            started_at=None,
            last_user_reply_at=None,
            assigned_intercom_inbox_id=None,
            assigned_intercom_admin_id=None,
            last_admin_reply_at=None,
            updated_at=None,
        ),
        tags=[ContactRequestTagEntity.from_model(tag) for tag in model.tags]
        if model.tags
        else [],
        app_id=model.app_id,
        app_user_id=model.app_user_id,
        legacy_conversation_context_id=model.legacy_conversation_context_id,
        legacy_conversation_context_app_id=model.legacy_conversation_context_app_id,
        language=model.language,
        source_id=model.source.id if model.source else None,
        created_at=model.created_at,
        updated_at=model.updated_at,
    )
intercom_state instance-attribute
intercom_state
tags instance-attribute
tags

conversation_overview

ConversationOverview dataclass

ConversationOverview(
    conversation_id,
    state,
    title,
    subtitle,
    start_date,
    notification=False,
)

Bases: DataClassJsonMixin

Overview of a member support conversation.

conversation_id instance-attribute
conversation_id
notification class-attribute instance-attribute
notification = False
start_date instance-attribute
start_date
state instance-attribute
state
subtitle instance-attribute
subtitle
title instance-attribute
title

conversation_response

ConversationDocument dataclass

ConversationDocument(id, name, uri, mime_type=None)

Bases: DataClassJsonMixin

id instance-attribute
id
mime_type class-attribute instance-attribute
mime_type = None
name instance-attribute
name
uri instance-attribute
uri

ConversationMessageResponse dataclass

ConversationMessageResponse(
    id,
    type,
    created_at,
    text,
    action_type=None,
    documents=None,
    voice_message=None,
    ctas=None,
)

Bases: DataClassJsonMixin

action_type class-attribute instance-attribute
action_type = None
created_at instance-attribute
created_at
ctas class-attribute instance-attribute
ctas = None
documents class-attribute instance-attribute
documents = None
id instance-attribute
id
text instance-attribute
text
type instance-attribute
type
voice_message class-attribute instance-attribute
voice_message = None

ConversationResponse dataclass

ConversationResponse(
    id, state, messages, available_actions, csat=None
)

Bases: DataClassJsonMixin

available_actions instance-attribute
available_actions
csat class-attribute instance-attribute
csat = None
id instance-attribute
id
messages instance-attribute
messages
state instance-attribute
state

ConversationVoiceMessage dataclass

ConversationVoiceMessage(id, uri)

Bases: DataClassJsonMixin

id instance-attribute
id
uri instance-attribute
uri

CtaType

Bases: AlanBaseEnum

escalation class-attribute instance-attribute
escalation = 'escalation'
navigation class-attribute instance-attribute
navigation = 'navigation'
satisfaction_response class-attribute instance-attribute
satisfaction_response = 'satisfaction_response'
satisfaction_survey class-attribute instance-attribute
satisfaction_survey = 'satisfaction_survey'

EscalationInfo dataclass

EscalationInfo(
    channel,
    is_already_requested=None,
    phone_request_id=None,
)

Bases: DataClassJsonMixin

channel instance-attribute
channel
is_already_requested class-attribute instance-attribute
is_already_requested = None
phone_request_id class-attribute instance-attribute
phone_request_id = None

MessageCta dataclass

MessageCta(type, params=None)

Bases: DataClassJsonMixin

params class-attribute instance-attribute
params = None
type instance-attribute
type

NavigationInfo dataclass

NavigationInfo(
    navigate_to,
    link_text,
    navigation_params=None,
    text_color=None,
    use_secondary_button=False,
    icon_visual=None,
    icon_variant=None,
    emoji_visual=None,
    url_path=None,
    is_external_url=False,
    disabled=False,
)

Bases: DataClassJsonMixin

disabled class-attribute instance-attribute
disabled = False
emoji_visual class-attribute instance-attribute
emoji_visual = None
icon_variant class-attribute instance-attribute
icon_variant = None
icon_visual class-attribute instance-attribute
icon_visual = None
is_external_url class-attribute instance-attribute
is_external_url = False
link_text
navigate_to instance-attribute
navigate_to
navigation_params class-attribute instance-attribute
navigation_params = None
text_color class-attribute instance-attribute
text_color = None
url_path class-attribute instance-attribute
url_path = None
use_secondary_button class-attribute instance-attribute
use_secondary_button = False

SatisfactionResponse dataclass

SatisfactionResponse(is_satisfied)

Bases: DataClassJsonMixin

is_satisfied instance-attribute
is_satisfied

csat_data

Public CSAT data entity.

CsatData dataclass

CsatData(rating, comment=None)

CSAT survey answer data.

comment class-attribute instance-attribute
comment = None
rating instance-attribute
rating

legacy_conversation_backlog

LegacyConversationAssignment dataclass

LegacyConversationAssignment(
    id,
    admin_id,
    action,
    rejection_reason,
    comment,
    reassigned_to_inbox_id,
    assigner_result,
    assigner_results_for_audit,
    assignment_type,
    created_at,
    updated_at,
)

This class represents a legacy Contact Request Assignment

action instance-attribute
action
admin_id instance-attribute
admin_id
assigner_result instance-attribute
assigner_result
assigner_results_for_audit instance-attribute
assigner_results_for_audit
assignment_type instance-attribute
assignment_type
comment instance-attribute
comment
created_at instance-attribute
created_at
id instance-attribute
id
reassigned_to_inbox_id instance-attribute
reassigned_to_inbox_id
rejection_reason instance-attribute
rejection_reason
updated_at instance-attribute
updated_at

LegacyConversationBacklog dataclass

LegacyConversationBacklog(
    admin_id,
    inbox_id,
    state,
    last_inbox_assignment_by,
    started_at,
)

This class represents a legacy Contact Request Intercom State

admin_id instance-attribute
admin_id
inbox_id instance-attribute
inbox_id
last_inbox_assignment_by instance-attribute
last_inbox_assignment_by
started_at instance-attribute
started_at
state instance-attribute
state

LegacyConversationTag dataclass

LegacyConversationTag(tag_name, tag_id, notified_at)

This class represents a legacy Contact Request Tag

notified_at instance-attribute
notified_at
tag_id instance-attribute
tag_id
tag_name instance-attribute
tag_name

legacy_support_agent

LegacySupportAgent dataclass

LegacySupportAgent(
    alan_email,
    intercom_admin_id,
    spoken_languages,
    work_location,
    is_external_admin,
    platform_name,
)

This class represents a legacy support agent.

alan_email instance-attribute
alan_email
intercom_admin_id instance-attribute
intercom_admin_id
is_external_admin instance-attribute
is_external_admin
platform_name instance-attribute
platform_name
spoken_languages instance-attribute
spoken_languages
work_location instance-attribute
work_location

LegacySupportAgentAffectation dataclass

LegacySupportAgentAffectation(
    intercom_workspace_id,
    is_assigner_enabled,
    roles,
    level,
    spe_matching_scores,
)

This class represents a legacy support agent affectation.

intercom_workspace_id instance-attribute
intercom_workspace_id
is_assigner_enabled instance-attribute
is_assigner_enabled
level instance-attribute
level
roles instance-attribute
roles
spe_matching_scores instance-attribute
spe_matching_scores

LegacySupportAgentSpeMatchingScore dataclass

LegacySupportAgentSpeMatchingScore(
    score, intercom_inbox_id
)

This class represents a legacy support agent spe matching score.

intercom_inbox_id instance-attribute
intercom_inbox_id
score instance-attribute
score

marmot_search_entities

MarmortSearchUserResult dataclass

MarmortSearchUserResult(
    *,
    user_id,
    first_name,
    last_name,
    email=None,
    address=None,
    birth_date=None,
    is_sensitive_user=False
)

Bases: DataClassJsonMixin

Results for users entity

address class-attribute instance-attribute
address = None
birth_date class-attribute instance-attribute
birth_date = None
email class-attribute instance-attribute
email = None
first_name instance-attribute
first_name
is_sensitive_user class-attribute instance-attribute
is_sensitive_user = False
last_name instance-attribute
last_name
user_id instance-attribute
user_id

MarmotSearchResult dataclass

MarmotSearchResult(*, users, error_message=None)

Bases: DataClassJsonMixin

error_message class-attribute instance-attribute
error_message = None
users instance-attribute
users

phone_support_entities

CallbackSuggestedTimeslot dataclass

CallbackSuggestedTimeslot(start, end, event_link=None)

Bases: DataClassJsonMixin

end instance-attribute
end
event_link = None
from_controller_param_timeslot classmethod
from_controller_param_timeslot(controller_param_timeslot)
Source code in components/support/subcomponents/phone_support/internal/entities/callback_suggested_timeslot.py
@classmethod
def from_controller_param_timeslot(
    cls, controller_param_timeslot: dict[str, Any]
) -> "CallbackSuggestedTimeslot":
    try:
        return CallbackSuggestedTimeslot(
            start=isoparse(controller_param_timeslot["start"]),
            end=isoparse(controller_param_timeslot["end"]),
            event_link=None,
        )
    except KeyError:
        raise ValueError("Provided suggested timeslot data is not valid")
start instance-attribute
start

HotlineQueue

Bases: AlanBaseEnum

PRIORITY class-attribute instance-attribute
PRIORITY = 'PRIORITY'
PUBLIC_SECTOR_RETIREE class-attribute instance-attribute
PUBLIC_SECTOR_RETIREE = 'PUBLIC_SECTOR_RETIREE'
REGULAR class-attribute instance-attribute
REGULAR = 'REGULAR'

INOPhoneCallStatus

Bases: AlanBaseEnum

attempt_limit class-attribute instance-attribute
attempt_limit = 'ATTEMPT_LIMIT'
canceled class-attribute instance-attribute
canceled = 'CANCELED'
card_not_found class-attribute instance-attribute
card_not_found = 'CARD_NOT_FOUND'
deprogrammed class-attribute instance-attribute
deprogrammed = 'DEPROGRAMMED'
dissuaded class-attribute instance-attribute
dissuaded = 'DISSUADED'
done class-attribute instance-attribute
done = 'DONE'
failed class-attribute instance-attribute
failed = 'FAILED'
machine_detected class-attribute instance-attribute
machine_detected = 'MACHINE_DETECTED'
max_try class-attribute instance-attribute
max_try = 'MAX_TRY'
no_phone_number class-attribute instance-attribute
no_phone_number = 'NO_PHONE_NUMBER'
no_response class-attribute instance-attribute
no_response = 'NO_RESPONSE'
no_user class-attribute instance-attribute
no_user = 'NO_USER'
not_posted class-attribute instance-attribute
not_posted = 'NOT_POSTED'
pending class-attribute instance-attribute
pending = 'PENDING'
treated class-attribute instance-attribute
treated = 'TREATED'

LegacyCallbackTimeslot dataclass

LegacyCallbackTimeslot(
    id,
    google_event_id,
    google_event_link,
    timeslot_start,
    timeslot_end,
    is_cancelled,
    is_reminder_sent,
    reminder_sent_at,
    alan_email,
    alan_employee_id,
    alan_employee_app_id,
)

Bases: DataClassJsonMixin

alan_email instance-attribute
alan_email
alan_employee_app_id instance-attribute
alan_employee_app_id
alan_employee_id instance-attribute
alan_employee_id
google_event_id instance-attribute
google_event_id
google_event_link
id instance-attribute
id
is_cancelled instance-attribute
is_cancelled
is_reminder_sent instance-attribute
is_reminder_sent
reminder_sent_at instance-attribute
reminder_sent_at
timeslot_end instance-attribute
timeslot_end
timeslot_start instance-attribute
timeslot_start

LegacyCallbackTimeslotsSuggestion dataclass

LegacyCallbackTimeslotsSuggestion(id, suggested_timeslots)

Bases: DataClassJsonMixin

id instance-attribute
id
suggested_timeslots instance-attribute
suggested_timeslots

LegacyPhoneCall dataclass

LegacyPhoneCall(
    id,
    ino_interaction_id,
    start,
    duration_in_seconds,
    alan_email,
    alan_employee_id,
    alan_employee_app_id,
    legacy_phone_call_recordings,
    created_at,
)

Bases: DataClassJsonMixin

Represents a phone call from legacy country-specific table

alan_email instance-attribute
alan_email
alan_employee_app_id instance-attribute
alan_employee_app_id
alan_employee_id instance-attribute
alan_employee_id
created_at instance-attribute
created_at
duration_in_seconds instance-attribute
duration_in_seconds
id instance-attribute
id
ino_interaction_id instance-attribute
ino_interaction_id
legacy_phone_call_recordings instance-attribute
legacy_phone_call_recordings
start instance-attribute
start

LegacyPhoneCallRecording dataclass

LegacyPhoneCallRecording(id, archived_at, uri, created_at)

Bases: DataClassJsonMixin

Represents a phone call recording from legacy country-specific table

archived_at instance-attribute
archived_at
created_at instance-attribute
created_at
id instance-attribute
id
uri instance-attribute
uri

NonOfflineUserInfo dataclass

NonOfflineUserInfo(
    is_eligible=False,
    is_alan_member=False,
    is_alaner=False,
    is_offline_member=False,
    is_offline_experience_company=False,
    redirect_to_queue=HotlineQueue.REGULAR,
)

Bases: DataClassJsonMixin

is_alan_member class-attribute instance-attribute
is_alan_member = False
is_alaner class-attribute instance-attribute
is_alaner = False
is_eligible class-attribute instance-attribute
is_eligible = False
is_offline_experience_company class-attribute instance-attribute
is_offline_experience_company = False
is_offline_member class-attribute instance-attribute
is_offline_member = False
redirect_to_queue class-attribute instance-attribute
redirect_to_queue = REGULAR

snooze_entities

PossibleCancelOnEventMetadata dataclass

PossibleCancelOnEventMetadata(metadata, display_data)

Bases: DataClassJsonMixin

A single possible cancel-on-event option with its storable metadata and display data.

display_data instance-attribute
display_data
metadata instance-attribute
metadata

SnoozeAutoCancelDataForCareEvent dataclass

SnoozeAutoCancelDataForCareEvent(
    user_id,
    user_first_name,
    care_event_id,
    care_event_status,
    care_event_emoji,
    care_event_label,
    care_event_date,
    care_event_url,
    user_marmot_url,
    insurance_profile_id,
)

Bases: DataClassJsonMixin

Data needed for care event auto-cancel logic (end notes, status checks, metadata completion).

care_event_date instance-attribute
care_event_date
care_event_emoji instance-attribute
care_event_emoji
care_event_id instance-attribute
care_event_id
care_event_label instance-attribute
care_event_label
care_event_status instance-attribute
care_event_status
care_event_url instance-attribute
care_event_url
insurance_profile_id instance-attribute
insurance_profile_id
user_first_name instance-attribute
user_first_name
user_id instance-attribute
user_id
user_marmot_url instance-attribute
user_marmot_url

SnoozeAutoCancelDataForTeletransmission dataclass

SnoozeAutoCancelDataForTeletransmission(
    insurance_profile_id,
    user_id,
    user_first_name,
    current_status,
    user_marmot_url,
)

Bases: DataClassJsonMixin

Data needed for teletransmission auto-cancel logic.

current_status instance-attribute
current_status
insurance_profile_id instance-attribute
insurance_profile_id
user_first_name instance-attribute
user_first_name
user_id instance-attribute
user_id
user_marmot_url instance-attribute
user_marmot_url

SnoozeSequenceSyncData dataclass

SnoozeSequenceSyncData(
    global_sequence_id,
    is_cancelled,
    cancelled_at,
    cancel_reason,
    cancelled_on_event,
    cancel_on_event,
    cancel_on_event_metadata,
    template_name,
    used_template_id,
    used_template_type,
    created_at,
    updated_at,
    intercom_admin_id,
    legacy_snooze_sequence_id=None,
    intercom_conversation_id=None,
    intercom_workspace_id=None,
    snoozes=list(),
)

Data for a full snooze sequence, used for bidirectional sync between legacy and global.

cancel_on_event instance-attribute
cancel_on_event
cancel_on_event_metadata instance-attribute
cancel_on_event_metadata
cancel_reason instance-attribute
cancel_reason
cancelled_at instance-attribute
cancelled_at
cancelled_on_event instance-attribute
cancelled_on_event
created_at instance-attribute
created_at
from_global_snooze_sequence staticmethod
from_global_snooze_sequence(snooze_sequence)

Build a SnoozeSequenceSyncData from a global SnoozeSequence model.

Source code in components/support/subcomponents/snooze/internal/compatibility/data/snooze_sequence_sync_data.py
@staticmethod
def from_global_snooze_sequence(
    snooze_sequence: SnoozeSequence,
) -> SnoozeSequenceSyncData:
    """Build a SnoozeSequenceSyncData from a global SnoozeSequence model."""
    return SnoozeSequenceSyncData(
        global_sequence_id=snooze_sequence.id,
        legacy_snooze_sequence_id=snooze_sequence.legacy_snooze_sequence_id,
        is_cancelled=snooze_sequence.is_cancelled,
        cancelled_at=snooze_sequence.cancelled_at,
        cancel_reason=snooze_sequence.cancel_reason,
        cancelled_on_event=snooze_sequence.cancelled_on_event,
        cancel_on_event=snooze_sequence.cancel_on_event,
        cancel_on_event_metadata=snooze_sequence.cancel_on_event_metadata,
        template_name=snooze_sequence.template_name,
        used_template_id=snooze_sequence.used_template_id,
        used_template_type=(
            snooze_sequence.used_template_type.value
            if snooze_sequence.used_template_type
            else None
        ),
        intercom_admin_id=mandatory(
            snooze_sequence.support_agent.intercom_admin_id
        ),
        intercom_conversation_id=snooze_sequence.contact_request.intercom_conversation_id,
        intercom_workspace_id=snooze_sequence.contact_request.intercom_workspace_id,
        snoozes=[
            SnoozeSyncData(
                snoozed_until=snooze.snoozed_until,
                snoozed_at=snooze.snoozed_at,
                unsnoozed_at=snooze.unsnoozed_at,
                target_time_string=snooze.target_time_string,
                on_unsnooze_reply_message=snooze.on_unsnooze_reply_message,
                replied_at=snooze.replied_at,
                on_unsnooze_close=snooze.on_unsnooze_close,
                closed_at=snooze.closed_at,
                on_unsnooze_leave_note=snooze.on_unsnooze_leave_note,
                note_left_at=snooze.note_left_at,
            )
            for snooze in snooze_sequence.snoozes
        ],
        created_at=snooze_sequence.created_at,
        updated_at=snooze_sequence.updated_at,
    )
global_sequence_id instance-attribute
global_sequence_id
intercom_admin_id instance-attribute
intercom_admin_id
intercom_conversation_id class-attribute instance-attribute
intercom_conversation_id = None
intercom_workspace_id class-attribute instance-attribute
intercom_workspace_id = None
is_cancelled instance-attribute
is_cancelled
legacy_snooze_sequence_id class-attribute instance-attribute
legacy_snooze_sequence_id = None
snoozes class-attribute instance-attribute
snoozes = field(default_factory=list)
template_name instance-attribute
template_name
updated_at instance-attribute
updated_at
used_template_id instance-attribute
used_template_id
used_template_type instance-attribute
used_template_type

SnoozeSyncData dataclass

SnoozeSyncData(
    snoozed_until,
    snoozed_at,
    unsnoozed_at,
    target_time_string,
    on_unsnooze_reply_message,
    replied_at,
    on_unsnooze_close,
    closed_at,
    on_unsnooze_leave_note,
    note_left_at,
)

Data for a single snooze step, used for bidirectional sync between legacy and global.

closed_at instance-attribute
closed_at
note_left_at instance-attribute
note_left_at
on_unsnooze_close instance-attribute
on_unsnooze_close
on_unsnooze_leave_note instance-attribute
on_unsnooze_leave_note
on_unsnooze_reply_message instance-attribute
on_unsnooze_reply_message
replied_at instance-attribute
replied_at
snoozed_at instance-attribute
snoozed_at
snoozed_until instance-attribute
snoozed_until
target_time_string instance-attribute
target_time_string
unsnoozed_at instance-attribute
unsnoozed_at

UserGreetingContext dataclass

UserGreetingContext(
    first_name,
    last_name,
    gender,
    should_use_formal_greeting,
)

Bases: DataClassJsonMixin

Data needed to interpolate greeting/salutation in snooze reply messages.

first_name instance-attribute
first_name
gender instance-attribute
gender
last_name instance-attribute
last_name
should_use_formal_greeting instance-attribute
should_use_formal_greeting

support_agent_for_response

CountryLocation

Bases: AlanBaseEnum

Work location of the support agents. Used to determine if the support agent is "offshore" or not. E.g. a support agent based in Morocco is considered offshore for France, and can't be assigned certain French company tickets (e.g. public sector etc).

be class-attribute instance-attribute
be = 'be'
ca class-attribute instance-attribute
ca = 'ca'
es class-attribute instance-attribute
es = 'es'
fr class-attribute instance-attribute
fr = 'fr'
ma class-attribute instance-attribute
ma = 'ma'
other class-attribute instance-attribute
other = 'other'

SpokenLanguage

Bases: AlanBaseEnum

Languages that can be spoken by a support agent. Uses ISO 639-1 two-letter language codes.

Current supported languages: - fr: French - en: English - es: Spanish - nl: Dutch - ca: Catalan

ca class-attribute instance-attribute
ca = 'ca'
en class-attribute instance-attribute
en = 'en'
es class-attribute instance-attribute
es = 'es'
fr class-attribute instance-attribute
fr = 'fr'
nl class-attribute instance-attribute
nl = 'nl'

SupportAgentRole

Bases: AlanBaseEnum

Roles that can be assigned to a support agent

automated_answer_reviewer class-attribute instance-attribute
automated_answer_reviewer = 'automated_answer_reviewer'
eligible_for_async class-attribute instance-attribute
eligible_for_async = 'eligible_for_async'
eligible_for_callback class-attribute instance-attribute
eligible_for_callback = 'eligible_for_callback'
eligible_for_sync class-attribute instance-attribute
eligible_for_sync = 'eligible_for_sync'

SupportAgentSpeMatchingScore

Bases: BaseModel

__table_args__ class-attribute instance-attribute
__table_args__ = (
    UniqueConstraint(
        "support_agent_workspace_affectation_id",
        "support_specialization_id",
        name="uq_support_agent_spe_matching_score_affectation_spe",
    ),
    {"schema": SUPPORT_SCHEMA_NAME},
)
__tablename__ class-attribute instance-attribute
__tablename__ = 'support_agent_spe_matching_score'
score class-attribute instance-attribute
score = mapped_column(Float, nullable=False, index=True)
support_agent_workspace_affectation class-attribute instance-attribute
support_agent_workspace_affectation = relationship(
    "SupportAgentWorkspaceAffectation",
    back_populates="spe_matching_scores",
)
support_agent_workspace_affectation_id class-attribute instance-attribute
support_agent_workspace_affectation_id = mapped_column(
    UUID(as_uuid=True),
    ForeignKey(id),
    nullable=False,
    index=True,
)
support_specialization class-attribute instance-attribute
support_specialization = relationship(
    "SupportSpecialization"
)
support_specialization_id class-attribute instance-attribute
support_specialization_id = mapped_column(
    UUID(as_uuid=True),
    ForeignKey(id),
    nullable=False,
    index=True,
)

SupportAgentWithAffectationForResponse dataclass

SupportAgentWithAffectationForResponse(
    id,
    work_location,
    spoken_languages,
    platform_name,
    affectations,
    alan_email,
    alan_employee_id,
    alan_employee_app_id,
    is_external_admin,
    is_deleted,
    intercom_admin_id,
)

Bases: DataClassJsonMixin

affectations instance-attribute
affectations
alan_email instance-attribute
alan_email
alan_employee_app_id instance-attribute
alan_employee_app_id
alan_employee_id instance-attribute
alan_employee_id
from_support_agent classmethod
from_support_agent(support_agent)
Source code in components/support/internal/entities/support_agent_for_response.py
@classmethod
def from_support_agent(
    cls,
    support_agent: SupportAgent,
) -> "SupportAgentWithAffectationForResponse":
    return SupportAgentWithAffectationForResponse(
        id=support_agent.id,
        work_location=support_agent.work_location,
        spoken_languages=support_agent.spoken_languages,
        platform_name=support_agent.platform_name,
        alan_email=support_agent.alan_email,
        alan_employee_id=support_agent.alan_employee_id,
        alan_employee_app_id=support_agent.alan_employee_app_id,
        is_external_admin=support_agent.is_external_admin,
        is_deleted=support_agent.is_deleted,
        intercom_admin_id=support_agent.intercom_admin_id,
        affectations=[
            SupportAgentWorkspaceAffectationForResponse.from_support_agent_workspace_affectation(
                affectation
            )
            for affectation in support_agent.affectations
        ],
    )
id instance-attribute
id
intercom_admin_id instance-attribute
intercom_admin_id
is_deleted instance-attribute
is_deleted
is_external_admin instance-attribute
is_external_admin
platform_name instance-attribute
platform_name
spoken_languages instance-attribute
spoken_languages
work_location instance-attribute
work_location

SupportAgentWorkspaceAffectation

Bases: BaseModel

__table_args__ class-attribute instance-attribute
__table_args__ = (
    UniqueConstraint(
        "support_agent_id",
        "intercom_workspace_id",
        name="uq_support_agent_workspace_affectation_agent_workspace",
    ),
    {"schema": SUPPORT_SCHEMA_NAME},
)
__tablename__ class-attribute instance-attribute
__tablename__ = 'support_agent_workspace_affectation'
intercom_workspace_id class-attribute instance-attribute
intercom_workspace_id = mapped_column(
    Text, nullable=False, index=True
)
is_active class-attribute instance-attribute
is_active = mapped_column(
    Boolean,
    nullable=False,
    server_default=true(),
    index=True,
)
is_assigner_enabled class-attribute instance-attribute
is_assigner_enabled = mapped_column(
    Boolean,
    nullable=False,
    server_default=false(),
    index=True,
)
level class-attribute instance-attribute
level = mapped_column(
    Integer,
    default=1,
    server_default="1",
    nullable=False,
    index=True,
)
roles class-attribute instance-attribute
roles = mapped_column(
    AlanBaseEnumArrayTypeDecorator(SupportAgentRole),
    nullable=False,
    default=[],
    index=True,
)
spe_matching_scores class-attribute instance-attribute
spe_matching_scores = relationship(
    "SupportAgentSpeMatchingScore",
    back_populates="support_agent_workspace_affectation",
    order_by="SupportAgentSpeMatchingScore.created_at.asc()",
)
support_agent class-attribute instance-attribute
support_agent = relationship(
    "SupportAgent", back_populates="affectations"
)
support_agent_id class-attribute instance-attribute
support_agent_id = mapped_column(
    UUID(as_uuid=True),
    ForeignKey(id),
    nullable=False,
    index=True,
)

tag_entities

AutomatedAnswerTag

Bases: AlanBaseEnum

automated_answer_in_app class-attribute instance-attribute
automated_answer_in_app = (
    "Automated answer[answered in app]"
)
automated_answer_waiting_for_review class-attribute instance-attribute
automated_answer_waiting_for_review = (
    "Automated answer[waiting for review]"
)

RoleTag

Bases: AlanBaseEnum

callback class-attribute instance-attribute
callback = 'ROLE[CALLBACK]'
catalan class-attribute instance-attribute
catalan = 'ROLE[CATALAN]'
dutch class-attribute instance-attribute
dutch = 'ROLE[DUTCH]'
english class-attribute instance-attribute
english = 'ROLE[ENGLISH]'
french class-attribute instance-attribute
french = 'ROLE[FRENCH]'
georestricted class-attribute instance-attribute
georestricted = 'ROLE[GEORESTRICTED]'
language_tag_for_language staticmethod
language_tag_for_language(tag_name)

Returns the language tag for a given language

Source code in components/support/internal/entities/tags.py
@staticmethod
def language_tag_for_language(tag_name: str) -> "RoleTag":
    """
    Returns the language tag for a given language
    """
    match tag_name.upper():
        case RoleTag.dutch.value:
            return RoleTag.dutch
        case RoleTag.french.value:
            return RoleTag.french
        case RoleTag.catalan.value:
            return RoleTag.catalan
        case RoleTag.spanish.value:
            return RoleTag.spanish
        case RoleTag.english.value:
            return RoleTag.english

    raise ValueError(f"Invalid language: {tag_name}")
level_1 class-attribute instance-attribute
level_1 = 'ROLE[LEVEL1]'
level_2 class-attribute instance-attribute
level_2 = 'ROLE[LEVEL2]'
level_3 class-attribute instance-attribute
level_3 = 'ROLE[LEVEL3]'
onepilot class-attribute instance-attribute
onepilot = 'ROLE[ONEPILOT]'
public_sector_cdc class-attribute instance-attribute
public_sector_cdc = 'Public Sector [CdC]'
public_sector_dgac class-attribute instance-attribute
public_sector_dgac = 'Public Sector [DGAC]'
public_sector_ecology class-attribute instance-attribute
public_sector_ecology = 'Public Sector [MTEECPR]'
public_sector_mef class-attribute instance-attribute
public_sector_mef = 'Public Sector [MEF]'
public_sector_spm class-attribute instance-attribute
public_sector_spm = 'Public Sector [SPM]'
role_level_tag_for_level staticmethod
role_level_tag_for_level(level)
Source code in components/support/internal/entities/tags.py
@staticmethod
def role_level_tag_for_level(level: int) -> "RoleTag":
    if level == 1:
        return RoleTag.level_1
    if level == 2:
        return RoleTag.level_2
    if level == 3:
        return RoleTag.level_3
    raise ValueError(f"Invalid role level: {level}")
role_public_sector_tag_by_entity staticmethod
role_public_sector_tag_by_entity(entity)
Source code in components/support/internal/entities/tags.py
@staticmethod
def role_public_sector_tag_by_entity(entity: str) -> "RoleTag":
    match entity.upper():
        case "MTEECPR":
            return RoleTag.public_sector_ecology
        case "SPM":
            return RoleTag.public_sector_spm
        case "MEF":
            return RoleTag.public_sector_mef
        case "DGAC":
            return RoleTag.public_sector_dgac
        case "CDC":
            return RoleTag.public_sector_cdc

    raise ValueError(f"Invalid entity: {entity}")
spanish class-attribute instance-attribute
spanish = 'ROLE[SPANISH]'
webhelp class-attribute instance-attribute
webhelp = 'ROLE[WEBHELP]'

SlaTag

Bases: AlanBaseEnum

sla_async class-attribute instance-attribute
sla_async = 'SLA[12h]'
sla_callback class-attribute instance-attribute
sla_callback = 'SLA[30min]'
sla_sync class-attribute instance-attribute
sla_sync = 'SLA[5min]'

components.support.public.enums

action_types

ActionType

Bases: AlanBaseEnum

Represents the type of action that can be taken in a conversation.

answer_csat_survey class-attribute instance-attribute
answer_csat_survey = 'answer_csat_survey'
close_conversation class-attribute instance-attribute
close_conversation = 'close_conversation'
dismiss_csat_survey class-attribute instance-attribute
dismiss_csat_survey = 'dismiss_csat_survey'
escalate class-attribute instance-attribute
escalate = 'escalate'
resume_conversation class-attribute instance-attribute
resume_conversation = 'resume_conversation'
select_end_conversation class-attribute instance-attribute
select_end_conversation = 'select_end_conversation'
select_followup_channel class-attribute instance-attribute
select_followup_channel = 'select_followup_channel'
select_satisfaction class-attribute instance-attribute
select_satisfaction = 'select_satisfaction'
select_start_new_conversation class-attribute instance-attribute
select_start_new_conversation = (
    "select_start_new_conversation"
)
send_email_input class-attribute instance-attribute
send_email_input = 'send_email_input'
send_message class-attribute instance-attribute
send_message = 'send_message'
submit_phone_conversation class-attribute instance-attribute
submit_phone_conversation = 'submit_phone_conversation'

care_channels

CareChannel

Bases: AlanBaseEnum

All channels that are available for care

callback class-attribute instance-attribute
callback = 'callback'
chat class-attribute instance-attribute
chat = 'chat'
email class-attribute instance-attribute
email = 'email'
hotline class-attribute instance-attribute
hotline = 'hotline'
immediate_callback class-attribute instance-attribute
immediate_callback = 'immediate_callback'

contact_request_source_type

ContactRequestSourceType

Bases: AlanBaseEnum

A ContactRequest instance's "source_type" represents how the user contacted us. It can be an Intercom sync conversation, an async email, a callback request, etc.

async_conversation_request class-attribute instance-attribute
async_conversation_request = 'async_conversation_request'
callback_request class-attribute instance-attribute
callback_request = 'callback_request'
hotline_request class-attribute instance-attribute
hotline_request = 'hotline_request'
immediate_callback_request class-attribute instance-attribute
immediate_callback_request = 'immediate_callback_request'
lead_callback_request class-attribute instance-attribute
lead_callback_request = 'lead_callback_request'
legacy_snooze_backfill class-attribute instance-attribute
legacy_snooze_backfill = 'legacy_snooze_backfill'
sync_conversation_request class-attribute instance-attribute
sync_conversation_request = 'sync_conversation_request'
unknown class-attribute instance-attribute
unknown = 'unknown'

contact_role_type

ContactRoleType

Bases: AlanBaseEnum

Tells the role of the user making the contact request, as seen from the apps.

admin class-attribute instance-attribute
admin = 'admin'
lead class-attribute instance-attribute
lead = 'lead'
member class-attribute instance-attribute
member = 'member'

conversation_state

ConversationState

Bases: AlanBaseEnum

Enum for member support conversation state

await_csat_response class-attribute instance-attribute
await_csat_response = 'awaiting_csat_response'
await_intercom_conversation_creation class-attribute instance-attribute
await_intercom_conversation_creation = (
    "awaiting_intercom_conversation_creation"
)
await_user_close_conversation class-attribute instance-attribute
await_user_close_conversation = (
    "awaiting_user_close_conversation"
)
awaiting_channel_selection class-attribute instance-attribute
awaiting_channel_selection = 'awaiting_channel_selection'
awaiting_email_input class-attribute instance-attribute
awaiting_email_input = 'awaiting_email_input'
awaiting_satisfaction class-attribute instance-attribute
awaiting_satisfaction = 'awaiting_satisfaction'
awaiting_unsatisfied_reason class-attribute instance-attribute
awaiting_unsatisfied_reason = 'awaiting_unsatisfied_reason'
awaiting_user_message class-attribute instance-attribute
awaiting_user_message = 'awaiting_user_message'
closed class-attribute instance-attribute
closed = 'closed'
escalated class-attribute instance-attribute
escalated = 'escalated'
processing class-attribute instance-attribute
processing = 'processing'

entry_point_origin

EntryPointOrigin

Bases: AlanBaseEnum

Represents the origin of an entry point.

mobile_member class-attribute instance-attribute
mobile_member = 'mobile_member'
mobile_public class-attribute instance-attribute
mobile_public = 'mobile_public'
web_admin class-attribute instance-attribute
web_admin = 'web_admin'
web_member class-attribute instance-attribute
web_member = 'web_member'
web_public class-attribute instance-attribute
web_public = 'web_public'

message_type

MessageType

Bases: AlanBaseEnum

Message type for support conversation messages

action_message class-attribute instance-attribute
action_message = 'action_message'
assistant_message class-attribute instance-attribute
assistant_message = 'assistant_message'
support_agent_action_message class-attribute instance-attribute
support_agent_action_message = (
    "support_agent_action_message"
)
support_agent_message class-attribute instance-attribute
support_agent_message = 'support_agent_message'
system_message class-attribute instance-attribute
system_message = 'system_message'
user_message class-attribute instance-attribute
user_message = 'user_message'

migration_status

MigrationStatus

Bases: AlanBaseEnum

Migration status of conversation from old model to new model on ai_assistant_conversation_part table

conversation_is_using_back_end_architecture_conversation class-attribute instance-attribute
conversation_is_using_back_end_architecture_conversation = "conversation_is_using_back_end_architecture_conversation"
conversation_not_migrated_as_there_is_no_conversation_snapshot class-attribute instance-attribute
conversation_not_migrated_as_there_is_no_conversation_snapshot = (
    "cannot_migrate"
)
conversation_to_migrate_to_back_end_architecture_conversation class-attribute instance-attribute
conversation_to_migrate_to_back_end_architecture_conversation = "conversation_to_migrate_to_back_end_architecture_conversation"

platform_type

PlatformType

Bases: AlanBaseEnum

Represents the platform type of a user.

android class-attribute instance-attribute
android = 'android'
ios class-attribute instance-attribute
ios = 'ios'
unknown class-attribute instance-attribute
unknown = 'unknown'
web class-attribute instance-attribute
web = 'web'

population_id

PopulationId

Bases: AlanBaseEnum

Population identifiers used for support channel management and cutoff periods. These identify different user groups for support availability.

admins_large_companies class-attribute instance-attribute
admins_large_companies = 'admins_large_companies'
admins_small_companies class-attribute instance-attribute
admins_small_companies = 'admins_small_companies'
all_admins class-attribute instance-attribute
all_admins = 'all_admins'
all_leads class-attribute instance-attribute
all_leads = 'all_leads'
all_members class-attribute instance-attribute
all_members = 'all_members'
ani_members class-attribute instance-attribute
ani_members = 'ani_members'
covered_members class-attribute instance-attribute
covered_members = 'covered_members'
everyone class-attribute instance-attribute
everyone = 'everyone'
non_covered_members class-attribute instance-attribute
non_covered_members = 'non_covered_members'
tns_members class-attribute instance-attribute
tns_members = 'tns_members'

support_csat_status

SupportCSATStatus

Bases: AlanBaseEnum

Status of a CSAT survey.

answered class-attribute instance-attribute
answered = 'ANSWERED'
dismissed class-attribute instance-attribute
dismissed = 'DISMISSED'
sent class-attribute instance-attribute
sent = 'SENT'

components.support.public.helpers

assignment_rejection_reasons

get_language_tag_from_rejection_reason

get_language_tag_from_rejection_reason(reason)

Returns the language tag from the rejection reason

Source code in components/support/subcomponents/assigner/protected/helpers/assignment_rejection_reasons.py
def get_language_tag_from_rejection_reason(
    reason: str,
) -> RoleTag | None:
    """
    Returns the language tag from the rejection reason
    """
    if reason == AssignmentRejectionReason.dutch_rejection:
        return RoleTag.dutch
    if reason == AssignmentRejectionReason.french_rejection:
        return RoleTag.french
    if reason == AssignmentRejectionReason.catalan_rejection:
        return RoleTag.catalan
    if reason == AssignmentRejectionReason.spanish_rejection:
        return RoleTag.spanish
    if reason == AssignmentRejectionReason.english_rejection:
        return RoleTag.english
    return None

is_callback_rejection_reason

is_callback_rejection_reason(reason)

Returns a boolean based on whether the rejection reason is due to the ticket being a callback ticket

Source code in components/support/subcomponents/assigner/protected/helpers/assignment_rejection_reasons.py
def is_callback_rejection_reason(reason: str) -> bool:
    """
    Returns a boolean based on whether the rejection reason is due to the ticket being a callback ticket
    """
    return reason is not None and reason == AssignmentRejectionReason.call_back_handover

is_handover_reason

is_handover_reason(reason)

Returns a boolean based on whether the rejection reason is due to a handover

Source code in components/support/subcomponents/assigner/protected/helpers/assignment_rejection_reasons.py
def is_handover_reason(reason: str) -> bool:
    """
    Returns a boolean based on whether the rejection reason is due to a handover
    """
    return reason is not None and reason in HANDOVER_REJECTION_REASONS

is_language_rejection_reason

is_language_rejection_reason(reason)

Returns a boolean based on whether the rejection reason is due to the ticket being in a language mismatch

Source code in components/support/subcomponents/assigner/protected/helpers/assignment_rejection_reasons.py
def is_language_rejection_reason(reason: str) -> bool:
    """
    Returns a boolean based on whether the rejection reason is due to the ticket being in a language mismatch
    """
    return reason is not None and reason in [
        AssignmentRejectionReason.dutch_rejection,
        AssignmentRejectionReason.french_rejection,
        AssignmentRejectionReason.catalan_rejection,
        AssignmentRejectionReason.spanish_rejection,
        AssignmentRejectionReason.english_rejection,
    ]

is_level_1_rejection_reason

is_level_1_rejection_reason(reason)

Returns a boolean based on whether the rejection reason is a "level 1" reason

Source code in components/support/subcomponents/assigner/protected/helpers/assignment_rejection_reasons.py
def is_level_1_rejection_reason(reason: str) -> bool:
    """
    Returns a boolean based on whether the rejection reason is a "level 1" reason
    """
    return reason is not None and reason in [
        AssignmentRejectionReason.level_1_workflow_rejection,
        AssignmentRejectionReason.level_1_workflow_handover,
    ]

is_level_2_rejection_reason

is_level_2_rejection_reason(reason)

Returns a boolean based on whether the rejection reason is a "level 2" reason

Source code in components/support/subcomponents/assigner/protected/helpers/assignment_rejection_reasons.py
def is_level_2_rejection_reason(reason: str) -> bool:
    """
    Returns a boolean based on whether the rejection reason is a "level 2" reason
    """
    return reason is not None and reason in [
        AssignmentRejectionReason.no_workflow_rejection,
        AssignmentRejectionReason.level_2_workflow_rejection,
        AssignmentRejectionReason.workflow_unclear_or_incomplete_handover,
        AssignmentRejectionReason.level_2_workflow_handover,
    ]

is_level_3_rejection_reason

is_level_3_rejection_reason(reason)

Returns a boolean based on whether the rejection reason is a "level 3" reason

Source code in components/support/subcomponents/assigner/protected/helpers/assignment_rejection_reasons.py
def is_level_3_rejection_reason(reason: str) -> bool:
    """
    Returns a boolean based on whether the rejection reason is a "level 3" reason
    """
    return reason is not None and reason in [
        AssignmentRejectionReason.level_3_workflow_rejection,
        AssignmentRejectionReason.level_3_workflow_handover,
    ]

get_hash_from_string

get_hash_from_string

get_hash_from_string(string)

Generate a deterministic UUID from str using MD5 hashing.

Source code in components/support/public/helpers/get_hash_from_string.py
def get_hash_from_string(string: str) -> UUID:
    """Generate a deterministic UUID from str using MD5 hashing."""
    import hashlib

    fields_hash = hashlib.md5()  # noqa: S324
    fields_hash.update(string.encode())

    return UUID(bytes=fields_hash.digest())

i18n

translate

translate(language, key_string, **kwargs)
Source code in components/support/internal/business_logic/i18n.py
def translate(language: Lang, key_string: str, **kwargs: Any) -> str:
    # Translate content with a fallback on French
    value = translate_str_from_dict(i18n, language, key_string, **kwargs)
    if language != Lang.french and value == "":
        current_logger.warning(
            f"Failed to find {language} translation for {key_string}"
        )
        value = translate_str_from_dict(i18n, Lang.french, key_string, **kwargs)

    return value

components.support.public.member_attributes

user_role module-attribute

user_role = MemberAttributeDefinition[ContactRoleType](
    name="user_role",
    display_name="User role",
    description="Role of the user making the contact (member, admin, or lead)",
    getter=_get_user_role,
    raw_type=ContactRoleType,
)

components.support.public.queries

csat_queries

Public queries for escalated conversation CSAT data.

Read-only lookups used by country handlers (e.g. FR) to inspect SupportCSAT records without directly accessing internal models.

get_escalated_csat

get_escalated_csat(
    intercom_conversation_id, intercom_workspace_id
)

Return CSAT data if the escalated CSAT has been answered, else None.

Source code in components/support/public/queries/csat_queries.py
def get_escalated_csat(
    intercom_conversation_id: str,
    intercom_workspace_id: str,
) -> CsatData | None:
    """Return CSAT data if the escalated CSAT has been answered, else None."""
    from components.support.internal.business_logic.queries.contact_request_queries import (
        get_or_none_contact_request_by_intercom_conversation,
    )
    from components.support.internal.business_logic.queries.support_csat_queries import (
        get_support_csat_by_contact_request_id,
    )
    from components.support.public.enums.support_csat_status import (
        SupportCSATStatus,
    )

    contact_request = get_or_none_contact_request_by_intercom_conversation(
        intercom_conversation_id=intercom_conversation_id,
        intercom_workspace_id=intercom_workspace_id,
    )
    if not contact_request:
        return None
    support_csat = get_support_csat_by_contact_request_id(contact_request.id)
    if not support_csat or support_csat.status != SupportCSATStatus.answered:
        return None

    if support_csat.rating is None:
        return None

    return CsatData(rating=support_csat.rating, comment=support_csat.comment)

get_escalated_pending_csat_conversation_ids

get_escalated_pending_csat_conversation_ids(
    intercom_conversation_ids, intercom_workspace_id
)

Return the subset of conversation IDs that have a pending (sent) SupportCSAT.

Source code in components/support/public/queries/csat_queries.py
def get_escalated_pending_csat_conversation_ids(
    intercom_conversation_ids: set[str],
    intercom_workspace_id: str,
) -> set[str]:
    """Return the subset of conversation IDs that have a pending (sent) SupportCSAT."""
    from sqlalchemy import select

    from components.support.internal.models.contact_request import ContactRequest
    from components.support.internal.models.support_csat import SupportCSAT
    from components.support.public.enums.support_csat_status import (
        SupportCSATStatus,
    )
    from shared.helpers.db import current_session

    if not intercom_conversation_ids:
        return set()

    rows = (
        current_session.execute(
            select(ContactRequest.intercom_conversation_id)
            .join(SupportCSAT, SupportCSAT.contact_request_id == ContactRequest.id)
            .where(
                ContactRequest.intercom_conversation_id.in_(intercom_conversation_ids),
                ContactRequest.intercom_workspace_id == intercom_workspace_id,
                SupportCSAT.status == SupportCSATStatus.sent,
            )
        )
        .scalars()
        .all()
    )

    return {r for r in rows if r is not None}

is_escalated_csat_pending

is_escalated_csat_pending(
    intercom_conversation_id, intercom_workspace_id
)

Check if an unanswered SupportCSAT exists for this Intercom conversation.

Source code in components/support/public/queries/csat_queries.py
def is_escalated_csat_pending(
    intercom_conversation_id: str,
    intercom_workspace_id: str,
) -> bool:
    """Check if an unanswered SupportCSAT exists for this Intercom conversation."""
    from components.support.internal.business_logic.queries.contact_request_queries import (
        get_or_none_contact_request_by_intercom_conversation,
    )
    from components.support.internal.business_logic.queries.support_csat_queries import (
        get_support_csat_by_contact_request_id,
    )
    from components.support.public.enums.support_csat_status import (
        SupportCSATStatus,
    )

    contact_request = get_or_none_contact_request_by_intercom_conversation(
        intercom_conversation_id=intercom_conversation_id,
        intercom_workspace_id=intercom_workspace_id,
    )
    if not contact_request:
        return False
    support_csat = get_support_csat_by_contact_request_id(contact_request.id)
    return support_csat is not None and support_csat.status == SupportCSATStatus.sent

documents

get_document

get_document(document_id)

Get the document name and document content by its ID.

Source code in components/support/public/queries/documents.py
def get_document(document_id: UUID) -> tuple[str, io.BytesIO] | None:
    """
    Get the document name and document content by its ID.
    """
    from components.documents.public.business_logic.document.queries import (
        get_document_content,
        get_document_info,
    )
    from shared.errors.error_code import BaseErrorCode

    try:
        document = get_document_content(document_id=document_id)

        document_info = get_document_info(document_id=document_id)

        document_name: str = (
            str(document_info.upload_metadata["filename"])
            if document_info.upload_metadata
            and "filename" in document_info.upload_metadata
            else document_info.filename
        )
        return document_name, document.file
    except BaseErrorCode as e:
        current_logger.error(
            f"support: get_document not found for document_id: {document_id}: {str(e)}",
        )
        return None

get_document_name_uri_type

get_document_name_uri_type(document_id)

Get the document name, uri and type content by its ID. :param document_id: id of the document :return: name, uri and type content

Source code in components/support/public/queries/documents.py
def get_document_name_uri_type(document_id: UUID) -> tuple[str, str, str | None]:
    """
    Get the document name, uri and type content by its ID.
    :param document_id: id of the document
    :return: name, uri and type content
    """
    from components.documents.public.business_logic.document.queries import (
        get_document_info,
        get_temporary_download_url,
    )

    document_info = get_document_info(document_id=document_id)
    document_uri = get_temporary_download_url(
        document_id=document_id, expires_in_seconds=30 * 60
    )

    document_name: str = (
        str(document_info.upload_metadata["filename"])
        if document_info.upload_metadata and "filename" in document_info.upload_metadata
        else document_info.filename
    )

    document_type = document_info.content_type

    return document_name, document_uri, document_type

intercom

get_intercom_conversation_as_messages

get_intercom_conversation_as_messages(
    conversation_id, intercom_workspace_id, language
)

Re-export from internal queries for cross-component access.

Source code in components/support/public/queries/intercom.py
def get_intercom_conversation_as_messages(
    conversation_id: str,
    intercom_workspace_id: str,
    language: "Lang",
) -> "list[ConversationMessageResponse]":
    """Re-export from internal queries for cross-component access."""
    from components.support.subcomponents.intercom.internal.queries.intercom_conversations_queries import (
        get_intercom_conversation_as_messages as _get_intercom_conversation_as_messages,
    )

    return _get_intercom_conversation_as_messages(
        conversation_id=conversation_id,
        intercom_workspace_id=intercom_workspace_id,
        language=language,
    )

get_intercom_user_id

get_intercom_user_id(
    user_id, intercom_client, is_pro=False
)

Get an Intercom user ID for the given user.

Source code in components/support/public/queries/intercom.py
def get_intercom_user_id(
    user_id: str, intercom_client: IntercomClient, is_pro: bool = False
) -> str | None:
    """Get an Intercom user ID for the given user."""
    from components.support.public.dependencies import get_app_dependency

    app_dependency = get_app_dependency()

    # For personal contacts
    perso_email = app_dependency.get_user_perso_email(user_id=user_id)
    perso_contact_id = intercom_client.get_perso_intercom_contact_id_by_alan_user_id(
        user_id=user_id
    ) or (
        intercom_client.get_intercom_contact_id_by_email(email=perso_email)
        if perso_email
        else None
    )

    # For professional contacts
    pro_email = app_dependency.get_user_pro_email(user_id=user_id)
    pro_contact_id = intercom_client.get_pro_intercom_contact_id_by_alan_user_id(
        user_id=user_id
    ) or (
        intercom_client.get_intercom_contact_id_by_email(email=pro_email)
        if pro_email
        else None
    )

    return pro_contact_id if is_pro else perso_contact_id

get_or_create_intercom_contact_id_from_user_id_and_profile_id

get_or_create_intercom_contact_id_from_user_id_and_profile_id(
    user_id, profile_id, intercom_client, is_pro=False
)

Get or create an Intercom contact ID for a given user and profile.

This function attempts to find an existing Intercom contact using the user's personal or professional email. If no contact is found, it creates a new one.

Parameters:

Name Type Description Default
user_id str

The Alan user ID

required
profile_id UUID | None

The user's profile ID (optional)

required
intercom_client IntercomClient

The Intercom client to use for API calls

required
is_pro bool

Whether the user is using a professional user account (admin or pro)

False

Returns:

Type Description
str

The Intercom contact ID (existing or newly created)

Source code in components/support/public/queries/intercom.py
def get_or_create_intercom_contact_id_from_user_id_and_profile_id(
    user_id: str,
    profile_id: UUID | None,
    intercom_client: IntercomClient,
    is_pro: bool = False,
) -> str:
    """
    Get or create an Intercom contact ID for a given user and profile.

    This function attempts to find an existing Intercom contact using the user's
    personal or professional email. If no contact is found, it creates a new one.

    Args:
        user_id: The Alan user ID
        profile_id: The user's profile ID (optional)
        intercom_client: The Intercom client to use for API calls
        is_pro: Whether the user is using a professional user account (admin or pro)

    Returns:
        The Intercom contact ID (existing or newly created)
    """
    from components.support.public.dependencies import get_app_dependency

    app_dependency = get_app_dependency()
    offline_user_email = f"{user_id}@offline.alan.invalid"

    # For personal contacts
    perso_email = app_dependency.get_user_perso_email(user_id=user_id)

    # For professional contacts
    pro_email = app_dependency.get_user_pro_email(user_id=user_id)

    email_for_intercom = (
        pro_email
        if is_pro and pro_email and pro_email != perso_email
        else perso_email or pro_email or offline_user_email
    )

    intercom_user_id = get_intercom_user_id(
        user_id=user_id, intercom_client=intercom_client, is_pro=is_pro
    )

    if not intercom_user_id:
        current_logger.info(
            "No intercom user ID found, creating new intercom user",
            user_id=user_id,
            profile_id=profile_id,
        )
        profile = None
        if profile_id is not None:
            profile_service = ProfileService.create()
            profile = profile_service.get_profile(profile_id)
        full_name = profile.full_name if profile is not None else f"{user_id}_offline"

        intercom_user_id = intercom_client.create_intercom_user(
            email=email_for_intercom,
            full_name=full_name,
            user_id=user_id,
        )
    return intercom_user_id

components.support.public.rules

get_conversation_body_for_intercom_receipt

get_conversation_body_for_intercom_receipt(conversation)

For a given IntercomConversation we get from the Intercom API or webhook payload, returns the message body without the [alan-attr] that's added automatically in async conversations, and returns a string version (no html).

Source code in components/support/public/rules.py
def get_conversation_body_for_intercom_receipt(
    conversation: IntercomConversation,
) -> str:
    """
    For a given IntercomConversation we get from the Intercom API or webhook payload,
    returns the message body *without* the [alan-attr] that's added automatically in async
    conversations, and returns a string version (no html).
    """
    from bs4 import BeautifulSoup

    soup = BeautifulSoup(conversation.conversation_message.body, "html.parser")
    split_message = soup.stripped_strings
    message = ""

    for part_message in split_message:
        message += f"{part_message}\n"

    # remove the alan-attr part from the message
    message = message.split("[alan-attr:")[0]
    return message

components.support.public.services

base_intercom_handler_service

BaseIntercomHandlerService

Bases: ABC

Base class for all Intercom handler services.

handle_conversation_admin_assigned abstractmethod staticmethod
handle_conversation_admin_assigned(
    intercom_workspace_id,
    intercom_conversation,
    notified_at,
)

Handle a conversation_admin_assigned topic.

Source code in components/support/public/services/base_intercom_handler_service.py
@staticmethod
@abstractmethod
def handle_conversation_admin_assigned(
    intercom_workspace_id: str,
    intercom_conversation: IntercomConversation,
    notified_at: datetime,
) -> None:
    """
    Handle a conversation_admin_assigned topic.
    """
handle_conversation_admin_closed abstractmethod staticmethod
handle_conversation_admin_closed(
    intercom_workspace_id,
    intercom_conversation,
    notified_at,
)

Handle a conversation_admin_closed topic.

Source code in components/support/public/services/base_intercom_handler_service.py
@staticmethod
@abstractmethod
def handle_conversation_admin_closed(
    intercom_workspace_id: str,
    intercom_conversation: IntercomConversation,
    notified_at: datetime,
) -> None:
    """
    Handle a conversation_admin_closed topic.
    """
handle_conversation_admin_opened abstractmethod staticmethod
handle_conversation_admin_opened(
    intercom_workspace_id,
    intercom_conversation,
    notified_at,
)

Handle a conversation_admin_opened topic.

Source code in components/support/public/services/base_intercom_handler_service.py
@staticmethod
@abstractmethod
def handle_conversation_admin_opened(
    intercom_workspace_id: str,
    intercom_conversation: IntercomConversation,
    notified_at: datetime,
) -> None:
    """
    Handle a conversation_admin_opened topic.
    """
handle_conversation_admin_replied abstractmethod staticmethod
handle_conversation_admin_replied(
    intercom_workspace_id,
    intercom_conversation,
    notified_at,
)

Handle a conversation_admin_replied topic.

Source code in components/support/public/services/base_intercom_handler_service.py
@staticmethod
@abstractmethod
def handle_conversation_admin_replied(
    intercom_workspace_id: str,
    intercom_conversation: IntercomConversation,
    notified_at: datetime,
) -> None:
    """
    Handle a conversation_admin_replied topic.
    """
handle_conversation_admin_single_created abstractmethod staticmethod
handle_conversation_admin_single_created(
    intercom_workspace_id,
    intercom_conversation,
    notified_at,
)

Handle a conversation_admin_single_created topic.

Source code in components/support/public/services/base_intercom_handler_service.py
@staticmethod
@abstractmethod
def handle_conversation_admin_single_created(
    intercom_workspace_id: str,
    intercom_conversation: IntercomConversation,
    notified_at: datetime,
) -> None:
    """
    Handle a conversation_admin_single_created topic.
    """
handle_conversation_admin_snoozed abstractmethod staticmethod
handle_conversation_admin_snoozed(
    intercom_workspace_id,
    intercom_conversation,
    notified_at,
)

Handle a conversation_admin_snoozed topic.

Source code in components/support/public/services/base_intercom_handler_service.py
@staticmethod
@abstractmethod
def handle_conversation_admin_snoozed(
    intercom_workspace_id: str,
    intercom_conversation: IntercomConversation,
    notified_at: datetime,
) -> None:
    """
    Handle a conversation_admin_snoozed topic.
    """
handle_conversation_admin_unsnoozed abstractmethod staticmethod
handle_conversation_admin_unsnoozed(
    intercom_workspace_id,
    intercom_conversation,
    notified_at,
)

Handle a conversation_admin_unsnoozed topic.

Source code in components/support/public/services/base_intercom_handler_service.py
@staticmethod
@abstractmethod
def handle_conversation_admin_unsnoozed(
    intercom_workspace_id: str,
    intercom_conversation: IntercomConversation,
    notified_at: datetime,
) -> None:
    """
    Handle a conversation_admin_unsnoozed topic.
    """
handle_conversation_deleted abstractmethod staticmethod
handle_conversation_deleted(
    intercom_workspace_id,
    intercom_conversation,
    notified_at,
)

Handle a conversation_deleted topic.

Source code in components/support/public/services/base_intercom_handler_service.py
@staticmethod
@abstractmethod
def handle_conversation_deleted(
    intercom_workspace_id: str,
    intercom_conversation: IntercomConversation,
    notified_at: datetime,
) -> None:
    """
    Handle a conversation_deleted topic.
    """
handle_conversation_part_tag_created abstractmethod staticmethod
handle_conversation_part_tag_created(
    intercom_workspace_id,
    intercom_conversation,
    notified_at,
)

Handle a conversation_part_tag_created topic.

Source code in components/support/public/services/base_intercom_handler_service.py
@staticmethod
@abstractmethod
def handle_conversation_part_tag_created(
    intercom_workspace_id: str,
    intercom_conversation: IntercomConversation,
    notified_at: datetime,
) -> None:
    """
    Handle a conversation_part_tag_created topic.
    """
handle_conversation_user_created abstractmethod staticmethod
handle_conversation_user_created(
    intercom_workspace_id,
    intercom_conversation,
    notified_at,
)

Handle a conversation_user_created topic.

Source code in components/support/public/services/base_intercom_handler_service.py
@staticmethod
@abstractmethod
def handle_conversation_user_created(
    intercom_workspace_id: str,
    intercom_conversation: IntercomConversation,
    notified_at: datetime,
) -> None:
    """
    Handle a conversation_user_created topic.
    """
handle_conversation_user_replied abstractmethod staticmethod
handle_conversation_user_replied(
    intercom_workspace_id,
    intercom_conversation,
    notified_at,
)

Handle a conversation_user_replied topic.

Source code in components/support/public/services/base_intercom_handler_service.py
@staticmethod
@abstractmethod
def handle_conversation_user_replied(
    intercom_workspace_id: str,
    intercom_conversation: IntercomConversation,
    notified_at: datetime,
) -> None:
    """
    Handle a conversation_user_replied topic.
    """
handle_new_conversation abstractmethod staticmethod
handle_new_conversation(
    intercom_workspace_id, intercom_conversation_id
)

Handle a new conversation.

Source code in components/support/public/services/base_intercom_handler_service.py
@staticmethod
@abstractmethod
def handle_new_conversation(
    intercom_workspace_id: str,
    intercom_conversation_id: str,
) -> None:
    """
    Handle a new conversation.
    """
handle_topic abstractmethod staticmethod
handle_topic(
    intercom_workspace_id,
    intercom_conversation,
    topic,
    notified_at,
    delivery_attempts,
)

Handle any topic, might be used to call other handlers.

Source code in components/support/public/services/base_intercom_handler_service.py
@staticmethod
@abstractmethod
def handle_topic(
    intercom_workspace_id: str,
    intercom_conversation: IntercomConversation,
    topic: IntercomWebhookTopic,
    notified_at: datetime,
    delivery_attempts: int,
) -> None:
    """
    Handle any topic, might be used to call other handlers.
    """

conversation_handler_service

ConversationHandlerService

Bases: ABC

Abstract interface for country-specific conversation handling.

Each country implements this to plug into the global ConversationStateMachine. The handler owns persistence and country-specific side effects (DB writes, DoctorAI triggers, Intercom integration, etc.).

apply_transition abstractmethod
apply_transition(new_state, conversation_id)

Apply a state transition to the conversation.

Source code in components/support/public/services/conversation_handler_service.py
@abstractmethod
def apply_transition(
    self, new_state: ConversationState, conversation_id: str
) -> None:
    """Apply a state transition to the conversation."""
claim_conversation
claim_conversation(user_id, token, origin, platform)

Claim a guest conversation after smart login. Returns conversation_id.

Source code in components/support/public/services/conversation_handler_service.py
def claim_conversation(
    self,
    user_id: str,
    token: str,
    origin: str,
    platform: str,
) -> str:
    """Claim a guest conversation after smart login. Returns conversation_id."""
    raise NotImplementedError
create_conversation
create_conversation(
    user_id,
    entry_point,
    origin,
    conversation_context=None,
    documents=None,
    message=None,
    voice_message_id=None,
    platform=PlatformType.unknown,
    app_version=None,
    conversation_language=None,
    channel=None,
    is_pro=None,
)

Create a new conversation and return its ID.

Source code in components/support/public/services/conversation_handler_service.py
def create_conversation(
    self,
    user_id: UUID | str | int | None,
    entry_point: str,  # noqa: ARG002
    origin: EntryPointOrigin,  # noqa: ARG002
    conversation_context: dict[str, Any] | None = None,
    documents: list[FileStorage] | None = None,
    message: str | None = None,
    voice_message_id: UUID | None = None,  # noqa: ARG002
    platform: PlatformType | None = PlatformType.unknown,  # noqa: ARG002
    app_version: str | None = None,  # noqa: ARG002
    conversation_language: Lang | None = None,  # noqa: ARG002
    channel: CareChannel | None = None,
    is_pro: bool | None = None,
) -> str | None:
    """Create a new conversation and return its ID."""
    from shared.helpers.typing import mandatory

    if user_id:
        return self._intercom.create_conversation(
            user_id=user_id,
            message=mandatory(message),
            channel=channel if channel else CareChannel.email,
            conversation_context=conversation_context,
            documents=documents,
            is_pro=is_pro or False,
        )

    # TODO manage loggout users
    return None
create_phone_conversation staticmethod
create_phone_conversation(
    user_id,
    channel,
    phone_number,
    phone_message,
    recording_consent,
    conversation_context=None,
)

Handle phone conversation submission — create callback request and link intercom conversation.

Source code in components/support/public/services/conversation_handler_service.py
@staticmethod
def create_phone_conversation(
    user_id: UUID | str | int,
    channel: CareChannel,
    phone_number: str,
    phone_message: str,
    recording_consent: bool,
    conversation_context: dict[str, Any] | None = None,
) -> UUID | None:
    """Handle phone conversation submission — create callback request and link intercom conversation."""
    import json

    from components.global_profile.public.api import ProfileService
    from components.support.subcomponents.phone_support.internal.actions.callback_request_actions import (
        create_callback_request_from_app,
    )
    from components.support.subcomponents.phone_support.internal.actions.quick_callback_request_actions import (
        create_quick_callback_request_from_app,
    )
    from shared.helpers.typing import mandatory

    profile_service = ProfileService.create()
    profile_id = profile_service.user_compat.get_profile_id_by_user_id(user_id)
    intercom_workspace_id = get_default_workspace_id_for_app_name(
        app_name=get_current_app_name()
    )

    # Build context from conversation context + phone fields
    context_dict: dict[str, Any] = dict(conversation_context or {})

    context_dict["callback_phone_number"] = phone_number
    context_dict["callback_consent"] = recording_consent
    context_dict["is_eligible_for_immediate_callback"] = (
        channel == CareChannel.immediate_callback
    )

    context_json = json.dumps(context_dict)

    phone_request_id: UUID | None = None
    if channel == CareChannel.callback:
        phone_request_id = create_callback_request_from_app(
            user_id=str(user_id),
            profile_id=mandatory(profile_id),
            intercom_workspace_id=intercom_workspace_id,
            message=phone_message,
            phone_number=phone_number,
            recording_consent=recording_consent,
            context=context_json,
        )
    elif channel == CareChannel.immediate_callback:
        phone_request_id = create_quick_callback_request_from_app(
            user_id=str(user_id),
            profile_id=mandatory(profile_id),
            intercom_workspace_id=intercom_workspace_id,
            message=phone_message,
            phone_number=phone_number,
            recording_consent=recording_consent,
            context=context_json,
            sync_conversation_creation=True,
        )

    return phone_request_id
get_conversation_channel abstractmethod
get_conversation_channel(conversation_id)

Retrieve a conversation channel.

Source code in components/support/public/services/conversation_handler_service.py
@abstractmethod
def get_conversation_channel(self, conversation_id: str) -> CareChannel | None:
    """Retrieve a conversation channel."""
get_conversation_messages abstractmethod
get_conversation_messages(
    conversation_id, is_authenticated=False
)

Load all messages for a conversation.

Source code in components/support/public/services/conversation_handler_service.py
@abstractmethod
def get_conversation_messages(
    self,
    conversation_id: str,
    is_authenticated: bool = False,
) -> list[ConversationMessageResponse]:
    """Load all messages for a conversation."""
get_conversation_owner_user_id abstractmethod
get_conversation_owner_user_id(conversation_id)

Return the user_id that owns this conversation, or None if not found.

Source code in components/support/public/services/conversation_handler_service.py
@abstractmethod
def get_conversation_owner_user_id(self, conversation_id: str) -> str | None:
    """Return the user_id that owns this conversation, or None if not found."""
get_conversation_state
get_conversation_state(conversation_id)

Load conversation state from ContactRequestIntercomState.

Source code in components/support/public/services/conversation_handler_service.py
def get_conversation_state(
    self,
    conversation_id: str,
) -> ConversationState:
    """Load conversation state from ContactRequestIntercomState."""
    return self._intercom.get_conversation_state(
        conversation_id=conversation_id,
    )
get_conversations_overview
get_conversations_overview(
    user_id, states, limit, contact_role
)

List conversation overviews for a user.

Source code in components/support/public/services/conversation_handler_service.py
def get_conversations_overview(
    self,
    user_id: UUID | str | int,
    states: list[ConversationState] | None,
    limit: int,
    contact_role: "ContactRoleType",
) -> list["ConversationOverview"]:
    """List conversation overviews for a user."""
    from components.global_profile.public.api import ProfileService
    from components.support.public.queries.intercom import (
        get_or_create_intercom_contact_id_from_user_id_and_profile_id,
    )

    app_name = get_current_app_name()
    workspace_id = get_default_workspace_id_for_app_name(app_name=app_name)
    intercom_client = get_intercom_client_for_workspace_id(
        workspace_id=workspace_id
    )

    profile_service = ProfileService.create()
    profile_id = profile_service.user_compat.get_profile_id_by_user_id(user_id)

    intercom_user_id = (
        get_or_create_intercom_contact_id_from_user_id_and_profile_id(
            user_id=user_id if type(user_id) is str else str(user_id),
            profile_id=profile_id,
            intercom_client=intercom_client,
            is_pro=contact_role == ContactRoleType.admin,
        )
    )
    intercom_states = self._map_conversation_states_to_intercom_conversation_states(
        states=states
    )
    conversations = self._intercom.get_intercom_conversations_overview(
        intercom_user_id=intercom_user_id,
        states=intercom_states,
        limit=limit,
    )
    return [self._to_conversation_overview(c) for c in conversations]
get_csat
get_csat(conversation_id)

Return CSAT data if CSAT has been answered, else None.

Source code in components/support/public/services/conversation_handler_service.py
def get_csat(self, conversation_id: str) -> CsatData | None:
    """Return CSAT data if CSAT has been answered, else None."""
    from components.support.public.queries.csat_queries import (
        get_escalated_csat,
    )

    return get_escalated_csat(
        intercom_conversation_id=conversation_id,
        intercom_workspace_id=get_default_workspace_id_for_app_name(
            app_name=get_current_app_name()
        ),
    )
get_initial_state
get_initial_state()

Return the initial state for a newly created conversation.

Source code in components/support/public/services/conversation_handler_service.py
def get_initial_state(self) -> ConversationState:
    """Return the initial state for a newly created conversation."""
    return ConversationState.awaiting_user_message
is_csat_pending
is_csat_pending(conversation_id)

Check if a CSAT survey is pending for this conversation.

Source code in components/support/public/services/conversation_handler_service.py
def is_csat_pending(self, conversation_id: str) -> bool:
    """Check if a CSAT survey is pending for this conversation."""
    from components.support.public.queries.csat_queries import (
        is_escalated_csat_pending,
    )

    return is_escalated_csat_pending(
        intercom_conversation_id=conversation_id,
        intercom_workspace_id=get_default_workspace_id_for_app_name(
            app_name=get_current_app_name()
        ),
    )
on_channel_selected abstractmethod
on_channel_selected(conversation_id, channel)

Handle follow-up channel selection.

Source code in components/support/public/services/conversation_handler_service.py
@abstractmethod
def on_channel_selected(
    self,
    conversation_id: str,
    channel: CareChannel,
) -> StateTransitionResult:
    """Handle follow-up channel selection."""
on_csat_dismissed
on_csat_dismissed(conversation_id)

Handle CSAT survey dismissal.

Source code in components/support/public/services/conversation_handler_service.py
def on_csat_dismissed(
    self,
    conversation_id: str,
) -> StateTransitionResult:
    """Handle CSAT survey dismissal."""
    from components.support.public.actions.csat_actions import (
        dismiss_escalated_csat,
    )

    dismiss_escalated_csat(
        intercom_conversation_id=conversation_id,
        intercom_workspace_id=get_default_workspace_id_for_app_name(
            app_name=get_current_app_name()
        ),
    )
    return StateTransitionResult(new_state=ConversationState.closed)
on_csat_response
on_csat_response(conversation_id, rating, comment=None)

Handle CSAT survey answer. Default uses SupportCSAT via Intercom conversation.

Source code in components/support/public/services/conversation_handler_service.py
def on_csat_response(
    self,
    conversation_id: str,
    rating: int,
    comment: str | None = None,
) -> StateTransitionResult:
    """Handle CSAT survey answer. Default uses SupportCSAT via Intercom conversation."""
    from components.support.public.actions.csat_actions import (
        answer_escalated_csat,
    )

    answer_escalated_csat(
        intercom_conversation_id=conversation_id,
        intercom_workspace_id=get_default_workspace_id_for_app_name(
            app_name=get_current_app_name()
        ),
        rating=rating,
        comment=comment,
    )
    return StateTransitionResult(new_state=ConversationState.closed)
on_email_input
on_email_input(conversation_id, email)

Handle email input from NLI user during escalation.

Source code in components/support/public/services/conversation_handler_service.py
def on_email_input(
    self,
    conversation_id: str,
    email: str,
) -> StateTransitionResult:
    """Handle email input from NLI user during escalation."""
    raise NotImplementedError
on_end_conversation abstractmethod
on_end_conversation(conversation_id)

Handle conversation end request.

Source code in components/support/public/services/conversation_handler_service.py
@abstractmethod
def on_end_conversation(
    self,
    conversation_id: str,
) -> StateTransitionResult:
    """Handle conversation end request."""
on_escalation abstractmethod
on_escalation(conversation_id)

Handle escalation request.

Source code in components/support/public/services/conversation_handler_service.py
@abstractmethod
def on_escalation(
    self,
    conversation_id: str,
) -> StateTransitionResult:
    """Handle escalation request."""
on_generate_answer abstractmethod
on_generate_answer(conversation_id)

Stream DoctorAI answer chunks. Handler owns DoctorAI call + persistence.

Source code in components/support/public/services/conversation_handler_service.py
@abstractmethod
def on_generate_answer(
    self,
    conversation_id: str,
) -> Generator[ConversationMessageResponse, None, None]:
    """Stream DoctorAI answer chunks. Handler owns DoctorAI call + persistence."""
on_reopen_conversation
on_reopen_conversation(conversation_id)

Handle conversation reopen by admin (closed → escalated).

Source code in components/support/public/services/conversation_handler_service.py
def on_reopen_conversation(
    self,
    conversation_id: str,  # noqa: ARG002
) -> StateTransitionResult:
    """Handle conversation reopen by admin (closed → escalated)."""
    return StateTransitionResult(new_state=ConversationState.escalated)
on_resume_conversation
on_resume_conversation(conversation_id)

Resume a conversation after smart login claim, triggering DoctorAI reclassification.

Source code in components/support/public/services/conversation_handler_service.py
def on_resume_conversation(
    self,
    conversation_id: str,
) -> StateTransitionResult:
    """Resume a conversation after smart login claim, triggering DoctorAI reclassification."""
    raise NotImplementedError
on_satisfaction_response abstractmethod
on_satisfaction_response(
    conversation_id, is_satisfied, message_id
)

Handle user satisfaction response.

Source code in components/support/public/services/conversation_handler_service.py
@abstractmethod
def on_satisfaction_response(
    self,
    conversation_id: str,
    is_satisfied: bool,
    message_id: str,
) -> StateTransitionResult:
    """Handle user satisfaction response."""
on_send_message
on_send_message(
    conversation_id,
    message,
    voice_message_id=None,
    documents=None,
)

Handle a user sending a message. Returns transition result.

Source code in components/support/public/services/conversation_handler_service.py
def on_send_message(
    self,
    conversation_id: str,
    message: str | None,
    voice_message_id: UUID | None = None,
    documents: list[FileStorage] | None = None,
) -> StateTransitionResult:
    """Handle a user sending a message. Returns transition result."""
    from shared.helpers.logging.logger import current_logger

    conversation_state = self.get_conversation_state(
        conversation_id=conversation_id
    )

    if conversation_state == ConversationState.closed:
        # We don't allow to re-open conversation that has been closed for now
        current_logger.warning(
            "ConversationHandlerService.on_send_message : conversation is closed, ignoring message",
            conversation_id=conversation_id,
        )
        return StateTransitionResult(ConversationState.closed)

    new_state = self._intercom.send_message(
        conversation_id=conversation_id,
        message=message or "",
        documents=documents,
        voice_message_id=voice_message_id,
    )

    return StateTransitionResult(new_state)
on_submit_phone_conversation
on_submit_phone_conversation(
    channel,
    phone_number,
    phone_message,
    recording_consent,
    conversation_context=None,
    conversation_id=None,
    user_id=None,
)

Handle phone conversation submission.

Source code in components/support/public/services/conversation_handler_service.py
def on_submit_phone_conversation(
    self,
    channel: CareChannel,
    phone_number: str,
    phone_message: str,
    recording_consent: bool,
    conversation_context: dict[str, Any] | None = None,
    conversation_id: str | None = None,
    user_id: UUID | str | int | None = None,
) -> StateTransitionResult:
    """Handle phone conversation submission."""
    from shared.helpers.logging.logger import current_logger

    if conversation_id:
        current_logger.warning("Conversation ID already set.")
        return StateTransitionResult(
            new_state=self.get_conversation_state(
                conversation_id=str(conversation_id)
            )
        )
    if user_id is None:
        current_logger.warning("User ID not set.")
        return StateTransitionResult(
            new_state=self.get_conversation_state(
                conversation_id=str(conversation_id)
            )
        )

    self.create_phone_conversation(
        user_id=user_id,
        channel=channel,
        phone_number=phone_number,
        phone_message=phone_message,
        recording_consent=recording_consent,
        conversation_context=conversation_context,
    )

    return StateTransitionResult(new_state=ConversationState.escalated)

StateTransitionResult dataclass

StateTransitionResult(new_state)

Result of a state transition, containing the new stat.

new_state instance-attribute
new_state

intercom_conversation_service

IntercomConversationService

Concrete Intercom operations extracted from ConversationHandlerService.

This is a standalone service (no inheritance relationship with handlers). Country-specific handlers delegate to this when they need Intercom interactions.

create_conversation
create_conversation(
    user_id,
    message,
    channel,
    conversation_context=None,
    documents=None,
    is_pro=False,
)

Create a new Intercom conversation and return its ID.

Source code in components/support/public/services/intercom_conversation_service.py
def create_conversation(
    self,
    user_id: UUID | str | int,
    message: str,
    channel: CareChannel,
    conversation_context: dict[str, Any] | None = None,
    documents: list[FileStorage] | None = None,
    is_pro: bool = False,
) -> str | None:
    """Create a new Intercom conversation and return its ID."""
    from components.support.subcomponents.intercom.internal.actions.intercom_conversations_actions import (
        create_intercom_conversation,
    )
    from shared.services.intercom.intercom_configuration import (
        get_default_workspace_id_for_app_name,
    )

    app_name = get_current_app_name()
    workspace_id = get_default_workspace_id_for_app_name(app_name=app_name)
    conversation_id = create_intercom_conversation(
        user_id=str(user_id),
        profile_id=None,
        conversation_type=IntercomConversationType.synchronous
        if channel == CareChannel.chat
        else IntercomConversationType.asynchronous,
        intercom_workspace_id=workspace_id,
        message=message,
        conversation_context=conversation_context,
        callback_phone_number=conversation_context.get("callback_phone_number")
        if conversation_context is not None
        else None,
        recording_consent=conversation_context.get("recording_consent")
        if conversation_context is not None
        else None,
        files=documents,
        is_pro=is_pro,
        source_type=self._get_source_type_from_channel(channel=channel),
    )

    return conversation_id
get_conversation_state
get_conversation_state(conversation_id)

Load conversation state from ContactRequestIntercomState.

Source code in components/support/public/services/intercom_conversation_service.py
def get_conversation_state(
    self,
    conversation_id: str,
) -> ConversationState:
    """Load conversation state from ContactRequestIntercomState."""
    from components.support.internal.business_logic.queries.contact_request_queries import (
        get_or_none_contact_request_by_intercom_conversation,
    )
    from shared.models.enums.intercom_conversation import (
        IntercomConversationState,
    )
    from shared.services.intercom.intercom_configuration import (
        get_default_workspace_id_for_app_name,
    )

    app_name = get_current_app_name()
    workspace_id = get_default_workspace_id_for_app_name(app_name=app_name)

    contact_request = get_or_none_contact_request_by_intercom_conversation(
        intercom_conversation_id=conversation_id,
        intercom_workspace_id=workspace_id,
    )
    intercom_state = contact_request.intercom_state if contact_request else None
    if not intercom_state:
        return ConversationState.awaiting_user_message

    match intercom_state.state:
        case IntercomConversationState.open | IntercomConversationState.snoozed:
            return ConversationState.awaiting_user_message
        case IntercomConversationState.closed | IntercomConversationState.deleted:
            return ConversationState.closed
get_intercom_conversations_overview
get_intercom_conversations_overview(
    intercom_user_id, states, limit=10
)

Fetch conversation overviews from Intercom.

Source code in components/support/public/services/intercom_conversation_service.py
def get_intercom_conversations_overview(
    self,
    intercom_user_id: str,
    states: list[IntercomConversationState],
    limit: int = 10,
) -> list[IntercomSearchConversation]:
    """Fetch conversation overviews from Intercom."""
    from shared.services.intercom.intercom_configuration import (
        get_default_workspace_id_for_app_name,
    )

    app_name = get_current_app_name()
    workspace_id = get_default_workspace_id_for_app_name(app_name=app_name)
    intercom_client = get_intercom_client_for_workspace_id(
        workspace_id=workspace_id
    )
    return intercom_client.search_user_conversations(
        intercom_user_id=intercom_user_id,
        limit=limit,
        intercom_states=states,
    )
send_message
send_message(
    conversation_id,
    message,
    documents=None,
    voice_message_id=None,
)

Send a member message on an existing Intercom conversation.

Returns the new conversation state.

Source code in components/support/public/services/intercom_conversation_service.py
def send_message(
    self,
    conversation_id: str,
    message: str,
    documents: list[FileStorage] | None = None,
    voice_message_id: UUID | None = None,
) -> ConversationState:
    """Send a member message on an existing Intercom conversation.

    Returns the new conversation state.
    """
    from components.support.subcomponents.intercom.internal.actions.intercom_conversations_actions import (
        leave_voice_note_on_intercom_conversation,
        send_member_intercom_message,
    )
    from shared.services.intercom.intercom_configuration import (
        get_default_workspace_id_for_app_name,
    )

    app_name = get_current_app_name()
    workspace_id = get_default_workspace_id_for_app_name(app_name=app_name)

    send_member_intercom_message(
        conversation_id=conversation_id,
        intercom_workspace_id=workspace_id,
        text=message,
        files_with_filenames=[(f.filename or "", f) for f in documents]
        if documents
        else None,
    )

    if voice_message_id:
        leave_voice_note_on_intercom_conversation(
            conversation_id=conversation_id,
            intercom_workspace_id=workspace_id,
            voice_message_id=voice_message_id,
        )

    return ConversationState.awaiting_user_message

phone_support_service

PhoneSupportService

Allows interacting with phone support features

create_callback_request staticmethod
create_callback_request(
    phone_number,
    recording_consent,
    contact_request_id,
    commit=True,
)

Create a callback request

Source code in components/support/public/services/phone_support_service.py
@staticmethod
def create_callback_request(
    phone_number: str | None,
    recording_consent: bool | None,
    contact_request_id: UUID,
    commit: bool = True,
) -> CallbackRequest:
    """
    Create a callback request
    """
    return create_callback_request(
        phone_number=phone_number,
        recording_consent=recording_consent,
        contact_request_id=contact_request_id,
        commit=commit,
    )
create_hotline_request staticmethod
create_hotline_request(
    phone_number,
    contact_request_id,
    ino_interaction_id,
    recording_consent=None,
    commit=True,
)

Create a hotline request

Source code in components/support/public/services/phone_support_service.py
@staticmethod
def create_hotline_request(
    phone_number: str | None,
    contact_request_id: UUID,
    ino_interaction_id: str,
    recording_consent: bool | None = None,
    commit: bool = True,
) -> HotlineCall | None:
    """
    Create a hotline request
    """
    from components.support.internal.business_logic.queries.contact_request_queries import (
        get_contact_request_by_id,
    )
    from components.support.internal.business_logic.queries.support_agent_queries import (
        get_support_agent_by_intercom_admin_id,
    )
    from components.support.subcomponents.phone_support.internal.actions.hotline_call_actions import (
        create_hotline_call,
    )
    from components.support.subcomponents.phone_support.internal.actions.phone_call_actions import (
        create_phone_call,
    )
    from shared.helpers.logging.logger import current_logger
    from shared.helpers.time.utc import utcnow

    contact_request = get_contact_request_by_id(
        contact_request_id=contact_request_id
    )

    if not contact_request:
        current_logger.warning(
            "create_hotline_request - No contact request found for contact_request_id",
            contact_request_id=contact_request_id,
        )
        return None

    support_agent_id = None
    if (
        contact_request.intercom_state
        and contact_request.intercom_state.assigned_intercom_admin_id
    ):
        support_agent = get_support_agent_by_intercom_admin_id(
            contact_request.intercom_state.assigned_intercom_admin_id
        )
        support_agent_id = support_agent.id if support_agent else None

    create_phone_call(
        support_agent_id=support_agent_id,
        contact_request_id=contact_request_id,
        ino_interaction_id=ino_interaction_id,
        start=utcnow(),
        commit=commit,
    )

    if not phone_number:
        current_logger.info(
            "create_hotline_request - No phone number provided",
            contact_request_id=contact_request_id,
        )
        return None

    return create_hotline_call(
        phone_number=phone_number,
        contact_request_id=contact_request_id,
        recording_consent=recording_consent,
        commit=commit,
    )
create_quick_callback_request staticmethod
create_quick_callback_request(
    phone_number,
    recording_consent,
    contact_request_id,
    status=INOPhoneCallStatus.not_posted,
    ino_voice_campaign_target_id=None,
    commit=True,
)

Create a quick callback request

Source code in components/support/public/services/phone_support_service.py
@staticmethod
def create_quick_callback_request(
    phone_number: str,
    recording_consent: bool,
    contact_request_id: UUID,
    status: INOPhoneCallStatus = INOPhoneCallStatus.not_posted,
    ino_voice_campaign_target_id: str | None = None,
    commit: bool = True,
) -> QuickCallbackRequest:
    """
    Create a quick callback request
    """
    return create_quick_callback_request(
        phone_number=phone_number,
        recording_consent=recording_consent,
        contact_request_id=contact_request_id,
        status=status,
        ino_voice_campaign_target_id=ino_voice_campaign_target_id,
        commit=commit,
    )
get_callback_request_id_for_contact_request staticmethod
get_callback_request_id_for_contact_request(
    contact_request_id,
)

Get a callback request

Source code in components/support/public/services/phone_support_service.py
@staticmethod
def get_callback_request_id_for_contact_request(
    contact_request_id: UUID,
) -> UUID | None:
    """
    Get a callback request
    """
    callback_request = get_callback_request_for_contact_request(
        contact_request_id=contact_request_id
    )

    return callback_request.id if callback_request else None
get_conversation_id_for_callback_request staticmethod
get_conversation_id_for_callback_request(
    callback_request_id,
)

Retrieve intercom conversation ID for a callback request

Source code in components/support/public/services/phone_support_service.py
@staticmethod
def get_conversation_id_for_callback_request(
    callback_request_id: UUID,
) -> str | None:
    """
    Retrieve intercom conversation ID for a callback request
    """
    from components.support.internal.business_logic.queries.contact_request_queries import (
        get_contact_request_by_callback_request_id,
    )

    contact_request = get_contact_request_by_callback_request_id(
        callback_request_id=callback_request_id
    )

    return contact_request.intercom_conversation_id if contact_request else None
get_conversation_id_for_quick_callback_request staticmethod
get_conversation_id_for_quick_callback_request(
    quick_callback_request_id,
)

Retrieve intercom conversation ID for a callback request

Source code in components/support/public/services/phone_support_service.py
@staticmethod
def get_conversation_id_for_quick_callback_request(
    quick_callback_request_id: UUID,
) -> str | None:
    """
    Retrieve intercom conversation ID for a callback request
    """
    from components.support.internal.business_logic.queries.contact_request_queries import (
        get_contact_request_by_quick_callback_request_id,
    )

    contact_request = get_contact_request_by_quick_callback_request_id(
        quick_callback_request_id=quick_callback_request_id
    )

    return contact_request.intercom_conversation_id if contact_request else None
handle_new_conversation staticmethod
handle_new_conversation(
    legacy_conversation_context_id,
    legacy_conversation_context_app_id,
    intercom_conversation,
)

To be called as a side effect to the creation of any new Intercom conversation. This will determine if the conversation should be handled as a phone support conversation, and handle it as such. Example: for callbacks, we need to send a specific async email initial answer.

Source code in components/support/public/services/phone_support_service.py
@staticmethod
def handle_new_conversation(
    legacy_conversation_context_id: str,
    legacy_conversation_context_app_id: AppName,
    intercom_conversation: IntercomConversation,
) -> bool:
    """
    To be called as a side effect to the creation of any new Intercom conversation.
    This will determine if the conversation should be handled as a phone support conversation, and
    handle it as such.
    Example: for callbacks, we need to send a specific async email initial answer.
    """
    from components.support.subcomponents.phone_support.internal.actions.callback_request_actions import (
        handle_new_conversation_for_callback_requests,
    )

    handled = handle_new_conversation_for_callback_requests(
        legacy_conversation_context_id=legacy_conversation_context_id,
        legacy_conversation_context_app_id=legacy_conversation_context_app_id,
        intercom_conversation=intercom_conversation,
    )

    return handled
is_user_eligible_to_callback staticmethod
is_user_eligible_to_callback(user_id)

Tells if a given user is eligible to create callback requests. Usually based off feature flags.

Source code in components/support/public/services/phone_support_service.py
@staticmethod
def is_user_eligible_to_callback(
    user_id: str | None,
) -> bool:
    """
    Tells if a given user is eligible to create callback requests. Usually based off feature flags.
    """
    return get_app_dependency().is_eligible_to_callback(user_id=user_id)
update_callback_request staticmethod
update_callback_request(
    callback_request_id,
    phone_number,
    recording_consent,
    commit=True,
)

Update a callback request

Source code in components/support/public/services/phone_support_service.py
@staticmethod
def update_callback_request(
    callback_request_id: UUID,
    phone_number: str | None,
    recording_consent: bool | None,
    commit: bool = True,
) -> None:
    """
    Update a callback request
    """
    callback_request = get_or_raise_missing_resource(
        CallbackRequest, callback_request_id
    )

    callback_request.phone_number = phone_number
    callback_request.recording_consent = recording_consent

    current_session.add(callback_request)

    if commit:
        current_session.commit()

subcomponents_services

AssignerService

This service provides methods to interact with the Support Assigner feature from other components.

update_contact_request_automated_answers_review_reserved_status staticmethod
update_contact_request_automated_answers_review_reserved_status(
    intercom_conversation_id,
    intercom_workspace_id,
    reserved_for_review,
    commit=True,
)

Used to update a contact request's reserved_for_automated_answers_review in the assignment metadata table. This is to be used until we globalise the automated answers, which is the feature that sets this field (in the legacy stack, this field used to be set on care_conversation_backlog). That field is used by the assigner to determine which conversations can be assigned to automated answers reviewers.

Source code in components/support/subcomponents/assigner/protected/services/assigner_service.py
@staticmethod
def update_contact_request_automated_answers_review_reserved_status(
    intercom_conversation_id: str,
    intercom_workspace_id: str,
    reserved_for_review: bool,
    commit: bool = True,
) -> None:
    """
    Used to update a contact request's reserved_for_automated_answers_review in the
    assignment metadata table. This is to be used until we globalise the automated answers,
    which is the feature that sets this field (in the legacy stack, this field used to be
    set on care_conversation_backlog).
    That field is used by the assigner to determine which conversations can be assigned
    to automated answers reviewers.
    """
    from components.support.internal.business_logic.queries.contact_request_queries import (
        get_contact_request_by_intercom_conversation,
    )
    from components.support.public.dependencies import get_app_dependency

    contact_request = get_contact_request_by_intercom_conversation(
        intercom_conversation_id=intercom_conversation_id,
        intercom_workspace_id=intercom_workspace_id,
    )

    if not contact_request:
        # Fallback to legacy context id if we're in processing a new conversation and don't yet
        # have a conversation id on the contact_request (this method is likely to be called from the
        # legacy new conversation handler, that's called *before* the the contact_request gets reattached
        # to the conversation id)
        legacy_conversation_context_id, _ = (
            get_app_dependency().get_legacy_conversation_context_id_for_intercom_conversation(
                intercom_conversation_id=intercom_conversation_id,
                intercom_workspace_id=intercom_workspace_id,
            )
        )
        if legacy_conversation_context_id:
            contact_request = get_contact_request_by_id(
                contact_request_id=uuid.UUID(legacy_conversation_context_id),
            )

        if not contact_request:
            current_logger.error(
                "update_contact_request_automated_answers_review_reserved_status - contact request not found for intercom conversation",
                intercom_conversation_id=intercom_conversation_id,
                intercom_workspace_id=intercom_workspace_id,
            )
            return

    contact_request_metadata = get_contact_request_metadata_entity(
        contact_request.id
    )
    contact_request_metadata.reserved_for_automated_answers_review = (
        reserved_for_review
    )
    persist_contact_request_metadata(contact_request_metadata, commit=commit)
update_contact_request_last_inbox_assignment_by_from_legacy staticmethod
update_contact_request_last_inbox_assignment_by_from_legacy(
    intercom_conversation_id,
    intercom_workspace_id,
    commit=True,
)

Used to update a contact request's last_inbox_assignment_by in the intercom state table. This is to be used until we globalise the classifier, which is the feature that sets this field (in the legacy stack, this field used to be set on care_conversation_backlog). That field is used by the assigner to help calculate the raw_expertise_matching_score.

Source code in components/support/subcomponents/assigner/protected/services/assigner_service.py
@staticmethod
def update_contact_request_last_inbox_assignment_by_from_legacy(
    intercom_conversation_id: str,
    intercom_workspace_id: str,
    commit: bool = True,
) -> None:
    """
    Used to update a contact request's last_inbox_assignment_by in the
    intercom state table. This is to be used until we globalise the classifier,
    which is the feature that sets this field (in the legacy stack, this field used to be
    set on care_conversation_backlog).
    That field is used by the assigner to help calculate the raw_expertise_matching_score.
    """
    from components.support.internal.business_logic.actions.contact_request_actions import (
        persist_contact_request_entity,
    )
    from components.support.internal.business_logic.queries.contact_request_queries import (
        get_contact_request_by_intercom_conversation,
    )
    from components.support.public.dependencies import get_app_dependency

    contact_request = get_contact_request_by_intercom_conversation(
        intercom_conversation_id=intercom_conversation_id,
        intercom_workspace_id=intercom_workspace_id,
    )

    if not contact_request:
        current_logger.error(
            "update_last_inbox_assignment_by - contact request not found for intercom conversation",
            intercom_conversation_id=intercom_conversation_id,
            intercom_workspace_id=intercom_workspace_id,
        )
        return

    legacy_last_inbox_assignment_by = (
        get_app_dependency().get_last_inbox_assignment_by_from_legacy(
            intercom_conversation_id
        )
    )
    last_inbox_assignment_by = None
    if legacy_last_inbox_assignment_by:
        try:
            last_inbox_assignment_by = ContactRequestAssignmentSource[
                legacy_last_inbox_assignment_by
            ]
        except KeyError:
            # if we can't convert to the proper enum we keep the None value
            pass
    contact_request.intercom_state.last_inbox_assignment_by = (
        last_inbox_assignment_by
    )
    persist_contact_request_entity(contact_request, commit=commit)

support_intercom_handler_service

SupportIntercomHandlerService

Bases: BaseIntercomHandlerService

Contains static methods to handle Intercom webhooks for Support.

handle_conversation_admin_assigned staticmethod
handle_conversation_admin_assigned(
    intercom_workspace_id,
    intercom_conversation,
    notified_at,
)

This method is called when a conversation is assigned to an admin.

Source code in components/support/public/services/support_intercom_handler_service.py
@staticmethod
def handle_conversation_admin_assigned(
    intercom_workspace_id: str,
    intercom_conversation: IntercomConversation,
    notified_at: datetime,
) -> None:
    """
    This method is called when a conversation is assigned to an admin.
    """
    from components.support.subcomponents.assigner.internal.business_logic.queries.assigner_queries import (
        retrieve_inbox_assignment_details,
    )
    from components.support.subcomponents.assigner.protected.services.assigner_intercom_handler_service import (
        AssignerIntercomHandlerService,
    )
    from shared.helpers.logging.logger import current_logger

    # Subcomponent handlers for conversation_admin_assigned topic:

    AssignerIntercomHandlerService.handle_conversation_admin_assigned_topic(
        intercom_workspace_id=intercom_workspace_id,
        intercom_conversation=intercom_conversation,
        notified_at=notified_at,
    )

    # Update assigned inbox & admin id in the db

    contact_request = mandatory(
        get_contact_request_by_intercom_conversation(
            intercom_conversation_id=intercom_conversation.id,
            intercom_workspace_id=intercom_workspace_id,
        )
    )
    current_logger.info(
        "support - assigner_handle_conversation_admin_assigned_topic - assignment values",
        conversation_id=intercom_conversation.id,
        intercom_workspace_id=intercom_workspace_id,
        assigned_intercom_admin_id_before_update=contact_request.intercom_state.assigned_intercom_admin_id,
        assigned_intercom_admin_id_after_update=intercom_conversation.admin_assignee_id,
        assigned_intercom_inbox_id_before_update=contact_request.intercom_state.assigned_intercom_inbox_id,
        assigned_intercom_inbox_id_after_update=intercom_conversation.team_assignee_id,
    )
    contact_request.intercom_state.assigned_intercom_admin_id = (
        intercom_conversation.admin_assignee_id
    )
    contact_request.intercom_state.assigned_intercom_inbox_id = (
        intercom_conversation.team_assignee_id
    )

    inbox_assignment_details = retrieve_inbox_assignment_details(
        conversation=intercom_conversation,
        intercom_workspace_id=intercom_workspace_id,
    )

    if inbox_assignment_details and inbox_assignment_details.inbox_has_changed():
        current_logger.info(
            "support - assigner_handle_conversation_admin_assigned_topic - tracking manual assignment",
            conversation_id=intercom_conversation.id,
            intercom_workspace_id=intercom_workspace_id,
        )

        contact_request.intercom_state.last_inbox_assignment_by = (
            inbox_assignment_details.assignment_source
        )
        current_logger.info(
            f"support - assigner_handle_conversation_admin_assigned_topic - tracking last_inbox_assignment_by ({inbox_assignment_details.assignment_source})",
            conversation_id=intercom_conversation.id,
            intercom_workspace_id=intercom_workspace_id,
            contact_request_id=contact_request.id,
            last_inbox_assignment_by=inbox_assignment_details.assignment_source,
        )

    persist_contact_request_entity(contact_request, commit=False)
handle_conversation_admin_closed staticmethod
handle_conversation_admin_closed(
    intercom_workspace_id,
    intercom_conversation,
    notified_at,
)

This method is called when a conversation is closed by an admin.

Source code in components/support/public/services/support_intercom_handler_service.py
@staticmethod
def handle_conversation_admin_closed(
    intercom_workspace_id: str,
    intercom_conversation: IntercomConversation,
    notified_at: datetime,
) -> None:
    """
    This method is called when a conversation is closed by an admin.
    """
    from components.support.internal.business_logic.actions.intercom_conversation_actions import (
        reopen_conversation_if_missing_tag_async,
    )
    from components.support.internal.rules.intercom_inboxes_closing_rules import (
        is_conversation_tagging_correct,
    )

    if not is_conversation_tagging_correct(
        intercom_conversation=intercom_conversation,
        intercom_workspace_id=intercom_workspace_id,
    ):
        reopen_conversation_if_missing_tag_async(
            intercom_conversation=intercom_conversation,
        )

    # Subcomponent handlers for conversation_admin_closed topic:

    SnoozeIntercomHandlerService.handle_conversation_admin_closed(
        intercom_workspace_id=intercom_workspace_id,
        intercom_conversation=intercom_conversation,
        notified_at=notified_at,
    )
handle_conversation_admin_opened staticmethod
handle_conversation_admin_opened(
    intercom_workspace_id,
    intercom_conversation,
    notified_at,
)

This method is called when a conversation is opened by an admin.

Source code in components/support/public/services/support_intercom_handler_service.py
@staticmethod
def handle_conversation_admin_opened(
    intercom_workspace_id: str,
    intercom_conversation: IntercomConversation,
    notified_at: datetime,
) -> None:
    """
    This method is called when a conversation is opened by an admin.
    """
    # Subcomponent handlers for conversation_admin_opened topic:

    SnoozeIntercomHandlerService.handle_conversation_admin_opened(
        intercom_workspace_id=intercom_workspace_id,
        intercom_conversation=intercom_conversation,
        notified_at=notified_at,
    )
handle_conversation_admin_replied staticmethod
handle_conversation_admin_replied(
    intercom_workspace_id,
    intercom_conversation,
    notified_at,
)

This method is called when an admin replies to a conversation.

Source code in components/support/public/services/support_intercom_handler_service.py
@staticmethod
def handle_conversation_admin_replied(
    intercom_workspace_id: str,
    intercom_conversation: IntercomConversation,
    notified_at: datetime,
) -> None:
    """
    This method is called when an admin replies to a conversation.
    """
    from components.support.subcomponents.assigner.protected.services.assigner_intercom_handler_service import (
        AssignerIntercomHandlerService,
    )

    # Subcomponent handlers for conversation_admin_replied topic:

    AssignerIntercomHandlerService.handle_conversation_admin_replied(
        intercom_workspace_id=intercom_workspace_id,
        intercom_conversation=intercom_conversation,
        notified_at=notified_at,
    )
handle_conversation_admin_single_created staticmethod
handle_conversation_admin_single_created(
    intercom_workspace_id,
    intercom_conversation,
    notified_at,
)

This method is called when a new conversation is created by an admin.

Source code in components/support/public/services/support_intercom_handler_service.py
@staticmethod
def handle_conversation_admin_single_created(
    intercom_workspace_id: str,
    intercom_conversation: IntercomConversation,
    notified_at: datetime,
) -> None:
    """
    This method is called when a new conversation is created by an admin.
    """
    from components.support.internal.business_logic.actions.contact_request_actions import (
        sync_or_create_contact_request_for_new_intercom_conversation,
    )

    # Sync the contact request with the conversation (intercom state, tags, etc.)

    sync_or_create_contact_request_for_new_intercom_conversation(
        intercom_workspace_id=intercom_workspace_id,
        intercom_conversation=intercom_conversation,
        notified_at=notified_at,
    )
handle_conversation_admin_snoozed staticmethod
handle_conversation_admin_snoozed(
    intercom_workspace_id,
    intercom_conversation,
    notified_at,
)

This method is called when a conversation is snoozed by an admin.

Note: Snooze sequence creation is handled via the API endpoint, not through webhooks. This handler exists for completeness but doesn't trigger any snooze-specific logic.

Source code in components/support/public/services/support_intercom_handler_service.py
@staticmethod
def handle_conversation_admin_snoozed(
    intercom_workspace_id: str,
    intercom_conversation: IntercomConversation,
    notified_at: datetime,
) -> None:
    """
    This method is called when a conversation is snoozed by an admin.

    Note: Snooze sequence creation is handled via the API endpoint, not through webhooks.
    This handler exists for completeness but doesn't trigger any snooze-specific logic.
    """
    # Subcomponent handlers for conversation_admin_snoozed topic:
    _ = (intercom_workspace_id, intercom_conversation, notified_at)
handle_conversation_admin_unsnoozed staticmethod
handle_conversation_admin_unsnoozed(
    intercom_workspace_id,
    intercom_conversation,
    notified_at,
)

This method is called when a conversation is unsnoozed (either manually or automatically).

Source code in components/support/public/services/support_intercom_handler_service.py
@staticmethod
def handle_conversation_admin_unsnoozed(
    intercom_workspace_id: str,
    intercom_conversation: IntercomConversation,
    notified_at: datetime,
) -> None:
    """
    This method is called when a conversation is unsnoozed (either manually or automatically).
    """
    # Subcomponent handlers for conversation_admin_unsnoozed topic:

    SnoozeIntercomHandlerService.handle_conversation_admin_unsnoozed(
        intercom_workspace_id=intercom_workspace_id,
        intercom_conversation=intercom_conversation,
        notified_at=notified_at,
    )
handle_conversation_deleted staticmethod
handle_conversation_deleted(
    intercom_workspace_id,
    intercom_conversation,
    notified_at,
)

This method is called when a conversation is deleted.

Source code in components/support/public/services/support_intercom_handler_service.py
@staticmethod
def handle_conversation_deleted(
    intercom_workspace_id: str,
    intercom_conversation: IntercomConversation,
    notified_at: datetime,
) -> None:
    """
    This method is called when a conversation is deleted.
    """
    # Subcomponent handlers for conversation_deleted topic:
    _ = (intercom_workspace_id, intercom_conversation, notified_at)
handle_conversation_part_tag_created staticmethod
handle_conversation_part_tag_created(
    intercom_workspace_id,
    intercom_conversation,
    notified_at,
)

This method is called when a tag is created for a conversation part.

Source code in components/support/public/services/support_intercom_handler_service.py
@staticmethod
def handle_conversation_part_tag_created(
    intercom_workspace_id: str,
    intercom_conversation: IntercomConversation,
    notified_at: datetime,
) -> None:
    """
    This method is called when a tag is created for a conversation part.
    """
    from components.support.internal.business_logic.actions.contact_request_tag_actions import (
        update_contact_request_tags_async,
    )

    update_contact_request_tags_async(
        intercom_workspace_id=intercom_workspace_id,
        intercom_conversation=intercom_conversation,
        notified_at=notified_at,
        commit=True,
    )
handle_conversation_rating_added staticmethod
handle_conversation_rating_added(
    intercom_workspace_id,
    intercom_conversation,
    notified_at,
)

Handle conversation rating added event.

Fetches full conversation details from Intercom API (webhook payload doesn't include rating data) and creates/updates SupportCSAT record.

Source code in components/support/public/services/support_intercom_handler_service.py
@staticmethod
def handle_conversation_rating_added(
    intercom_workspace_id: str,
    intercom_conversation: IntercomConversation,
    notified_at: datetime,
) -> None:
    """
    Handle conversation rating added event.

    Fetches full conversation details from Intercom API (webhook payload doesn't
    include rating data) and creates/updates SupportCSAT record.
    """
    from components.support.internal.business_logic.actions.support_csat_actions import (
        create_or_update_support_csat_from_intercom_rating,
    )
    from shared.helpers.logging.logger import current_logger
    from shared.helpers.time.date import timestamp_to_utc_datetime
    from shared.services.intercom.intercom_configuration import (
        get_intercom_client_for_workspace_id,
    )

    current_logger.info(
        "SupportIntercomHandlerService.handle_conversation_rating_added",
        conversation_id=intercom_conversation.id,
        workspace_id=intercom_workspace_id,
    )

    # Fetch full conversation details from API (webhook doesn't include rating data)
    try:
        intercom_client = get_intercom_client_for_workspace_id(
            intercom_workspace_id
        )
        conversation_details = intercom_client.get_conversation_details(
            intercom_conversation.id
        )
    except Exception as e:
        current_logger.error(
            "handle_conversation_rating_added: Failed to fetch conversation details",
            conversation_id=intercom_conversation.id,
            workspace_id=intercom_workspace_id,
            error=str(e),
            exc_info=True,
        )
        return

    # Extract rating data from conversation_rating field
    conversation_rating = conversation_details.get("conversation_rating")
    if not conversation_rating:
        current_logger.warning(
            "handle_conversation_rating_added: No conversation_rating in response",
            conversation_id=intercom_conversation.id,
            workspace_id=intercom_workspace_id,
        )
        return

    rating = conversation_rating.get("rating")
    if rating is None:
        current_logger.warning(
            "handle_conversation_rating_added: Rating is null in conversation_rating",
            conversation_id=intercom_conversation.id,
            workspace_id=intercom_workspace_id,
        )
        return

    comment = conversation_rating.get("remark")
    teammate = conversation_rating.get("teammate", {})
    intercom_teammate_id = teammate.get("id") if teammate else None

    # Extract survey_sent_at from created_at (per Intercom docs: "time rating was requested")
    survey_sent_at = None
    created_at_timestamp = conversation_rating.get("created_at")
    if created_at_timestamp:
        survey_sent_at = timestamp_to_utc_datetime(created_at_timestamp)

    # Create or update SupportCSAT
    create_or_update_support_csat_from_intercom_rating(
        intercom_conversation_id=intercom_conversation.id,
        intercom_workspace_id=intercom_workspace_id,
        rating=rating,
        comment=comment,
        intercom_teammate_id=intercom_teammate_id,
        survey_sent_at=survey_sent_at,
        member_reply_at=notified_at,  # Webhook notification time ≈ when user rated
        commit=False,  # Committed in handle_topic
    )
handle_conversation_user_created staticmethod
handle_conversation_user_created(
    intercom_workspace_id,
    intercom_conversation,
    notified_at,
)

This method is called when a new conversation is created by a user.

Source code in components/support/public/services/support_intercom_handler_service.py
@staticmethod
def handle_conversation_user_created(
    intercom_workspace_id: str,
    intercom_conversation: IntercomConversation,
    notified_at: datetime,
) -> None:
    """
    This method is called when a new conversation is created by a user.
    """
    from components.support.internal.business_logic.actions.contact_request_actions import (
        sync_or_create_contact_request_for_new_intercom_conversation,
    )

    # Sync the contact request with the conversation (intercom state, tags, etc.)

    sync_or_create_contact_request_for_new_intercom_conversation(
        intercom_workspace_id=intercom_workspace_id,
        intercom_conversation=intercom_conversation,
        notified_at=notified_at,
    )
handle_conversation_user_replied staticmethod
handle_conversation_user_replied(
    intercom_workspace_id,
    intercom_conversation,
    notified_at,
)

This method is called when a user replies to a conversation.

Source code in components/support/public/services/support_intercom_handler_service.py
@staticmethod
def handle_conversation_user_replied(
    intercom_workspace_id: str,
    intercom_conversation: IntercomConversation,
    notified_at: datetime,
) -> None:
    """
    This method is called when a user replies to a conversation.
    """
    from components.support.subcomponents.assigner.protected.services.assigner_intercom_handler_service import (
        AssignerIntercomHandlerService,
    )

    # Subcomponent handlers for conversation_admin_replied topic:

    AssignerIntercomHandlerService.handle_conversation_user_replied(
        intercom_workspace_id=intercom_workspace_id,
        intercom_conversation=intercom_conversation,
        notified_at=notified_at,
    )

    SnoozeIntercomHandlerService.handle_conversation_user_replied(
        intercom_workspace_id=intercom_workspace_id,
        intercom_conversation=intercom_conversation,
        notified_at=notified_at,
    )
handle_new_conversation staticmethod
handle_new_conversation(
    intercom_workspace_id, intercom_conversation_id
)

Handles a new conversation event coming from Intercom (webhook or workflow API call)

Source code in components/support/public/services/support_intercom_handler_service.py
@staticmethod
def handle_new_conversation(
    intercom_workspace_id: str,
    intercom_conversation_id: str,
) -> None:
    """
    Handles a new conversation event coming from Intercom (webhook or workflow API call)
    """
    from components.support.internal.business_logic.actions.contact_request_actions import (
        mark_contact_request_as_processed_as_new_conversation,
        sync_or_create_contact_request_for_new_intercom_conversation,
    )
    from components.support.internal.business_logic.queries.intercom_webhooks_queries import (
        is_global_intercom_webhook_active,
    )
    from shared.helpers.logging.logger import current_logger

    # Getting the Intercom conversation details from the API (here we only have the conversation id)
    intercom_client = get_care_intercom_client(get_current_app_name())
    conversation_dict = intercom_client.get_conversation_details(
        conversation_id=intercom_conversation_id
    )
    intercom_conversation = convert_to_intercom_conversation(conversation_dict)

    last_part = get_last_conversation_part(intercom_conversation)
    delivered_at = int(datetime.now(UTC).timestamp())

    current_logger.info(
        "SupportIntercomHandlerService.handle_new_conversation",
        conversation_id=intercom_conversation.id,
        workspace_id=intercom_workspace_id,
        admin_assignee_id=intercom_conversation.admin_assignee_id,
        team_assignee_id=intercom_conversation.team_assignee_id,
        last_part_author_id=get_last_conversation_part_author_id(
            intercom_conversation
        ),
        conversation_user=anonymize_user(intercom_conversation.user),
        links=intercom_conversation.links,
        state=intercom_conversation.state,
        parts_summary=parts_summary(intercom_conversation),
        delivered_at=delivered_at,
        conversation_created_at=intercom_conversation.created_at,
        conversation_updated_at=intercom_conversation.updated_at,
        last_part_created_at=last_part.created_at if last_part else None,
        tags=intercom_conversation.tags,
    )

    # If we need to handle the event on the legacy side (non globalised features)
    # and need to do them synchronously, we can do it through the injected dependency.
    # Used in BE.
    try:
        if is_global_intercom_webhook_active(get_current_app_name()):
            get_app_dependency().handle_legacy_new_conversation_event(
                conversation=intercom_conversation
            )
    except Exception as e:
        current_logger.error(
            "SuportIntercomHandlerService.handle_new_conversation - error while calling handle_legacy_new_conversation_event",
            error=e,
            conversation_id=intercom_conversation.id,
            wokspace_id=intercom_workspace_id,
        )

    # Find & sync the relevant contact_request with that conversation (contact request intercom state, contact request tags, etc.)
    contact_request = sync_or_create_contact_request_for_new_intercom_conversation(
        intercom_workspace_id=intercom_workspace_id,
        intercom_conversation=intercom_conversation,
        notified_at=timestamp_to_utc_datetime(intercom_conversation.created_at)
        or datetime.now(UTC),
    )

    if not contact_request:
        current_logger.error(
            "support - handle_new_conversation - no contact request found for conversation",
            conversation_id=intercom_conversation.id,
            workspace_id=intercom_workspace_id,
        )
        return

    if contact_request.has_been_processed_as_new_conversation:
        current_logger.info(
            "support - handle_new_conversation - Contact request has already been processed as new conversation, stopping there",
            conversation_id=intercom_conversation.id,
            contact_request_id=contact_request.id,
        )
        return

    contact_request = mark_contact_request_as_processed_as_new_conversation(
        contact_request.id
    )
    # Assignment metadata
    set_recommended_admin_for_contact_request(contact_request.id)

    # Subcomponent handlers for new conversation:

    QuickCallbackIntercomHandlerService.handle_new_conversation(
        intercom_workspace_id=intercom_workspace_id,
        intercom_conversation_id=intercom_conversation.id,
    )
    AssignerService.update_contact_request_last_inbox_assignment_by_from_legacy(
        intercom_conversation_id=str(intercom_conversation_id),
        intercom_workspace_id=intercom_workspace_id,
    )

    HotlineIntercomHandlerService.handle_new_conversation(
        intercom_workspace_id=intercom_workspace_id,
        intercom_conversation_id=intercom_conversation.id,
    )
handle_topic staticmethod
handle_topic(
    intercom_workspace_id,
    intercom_conversation,
    topic,
    notified_at,
    delivery_attempts,
)

Handles a conversation webhook topic coming from Intercom

Source code in components/support/public/services/support_intercom_handler_service.py
@staticmethod
def handle_topic(
    intercom_workspace_id: str,
    intercom_conversation: IntercomConversation,
    topic: IntercomWebhookTopic,
    notified_at: datetime,
    delivery_attempts: int,
) -> None:
    """
    Handles a conversation webhook topic coming from Intercom
    """
    from components.growth.public.api import (
        GrowthIntercomHandlerService,
    )
    from components.support.internal.business_logic.actions.contact_request_intercom_state_actions import (
        update_contact_request_conversation_state,
    )
    from components.support.internal.rules.contact_request_rules import (
        can_conversation_webhook_topics_be_handled,
    )
    from components.support.subcomponents.assigner.protected.services.assigner_intercom_handler_service import (
        AssignerIntercomHandlerService,
    )
    from components.support.subcomponents.intercom.protected.services.pusher_intercom_handler_service import (
        PusherIntercomHandlerService,
    )
    from components.support.subcomponents.intercom.protected.services.sync_conversation_state_intercom_handler_service import (
        SyncConversationStateIntercomHandlerService,
    )
    from shared.helpers.logging.logger import current_logger

    current_logger.info(
        "SupportIntercomHandlerService.handle_topic",
        conversation_id=intercom_conversation.id,
        workspace_id=intercom_workspace_id,
        topic=topic,
        delivery_attempts=delivery_attempts,
    )

    # SyncConversationStateIntercomHandlerService must run before PusherIntercomHandlerService:
    # it creates the CSAT record (via state machine) that the Pusher handler checks before
    # sending the CSAT push notification.
    SyncConversationStateIntercomHandlerService.handle_topic(
        intercom_workspace_id=intercom_workspace_id,
        intercom_conversation=intercom_conversation,
        topic=topic,
        notified_at=notified_at,
        delivery_attempts=delivery_attempts,
    )

    PusherIntercomHandlerService.handle_topic(
        intercom_workspace_id=intercom_workspace_id,
        intercom_conversation=intercom_conversation,
        topic=topic,
        notified_at=notified_at,
        delivery_attempts=delivery_attempts,
    )

    if not can_conversation_webhook_topics_be_handled(
        intercom_workspace_id=intercom_workspace_id,
        intercom_conversation_id=intercom_conversation.id,
    ):
        current_logger.info(
            "SupportService.handle_topic - skipping topic handling because conversation is not yet processed as new conversation",
            topic=topic,
            conversation_id=intercom_conversation.id,
            state=intercom_conversation.state,
        )
        return

    # Update contact_request state
    update_contact_request_conversation_state(
        intercom_conversation_id=intercom_conversation.id,
        intercom_workspace_id=intercom_workspace_id,
        state=intercom_conversation.state,
        started_at=datetime.fromtimestamp(intercom_conversation.created_at, UTC),
    )

    if topic == IntercomWebhookTopic.conversation_user_created:
        SupportIntercomHandlerService.handle_conversation_user_created(
            intercom_workspace_id=intercom_workspace_id,
            intercom_conversation=intercom_conversation,
            notified_at=notified_at,
        )
    if topic == IntercomWebhookTopic.conversation_admin_single_created:
        SupportIntercomHandlerService.handle_conversation_admin_single_created(
            intercom_workspace_id=intercom_workspace_id,
            intercom_conversation=intercom_conversation,
            notified_at=notified_at,
        )
    if topic == IntercomWebhookTopic.conversation_admin_assigned:
        SupportIntercomHandlerService.handle_conversation_admin_assigned(
            intercom_workspace_id=intercom_workspace_id,
            intercom_conversation=intercom_conversation,
            notified_at=notified_at,
        )
    if topic == IntercomWebhookTopic.conversation_admin_closed:
        SupportIntercomHandlerService.handle_conversation_admin_closed(
            intercom_workspace_id=intercom_workspace_id,
            intercom_conversation=intercom_conversation,
            notified_at=notified_at,
        )
    if topic == IntercomWebhookTopic.conversation_admin_opened:
        SupportIntercomHandlerService.handle_conversation_admin_opened(
            intercom_workspace_id=intercom_workspace_id,
            intercom_conversation=intercom_conversation,
            notified_at=notified_at,
        )
    if topic == IntercomWebhookTopic.conversation_admin_snoozed:
        SupportIntercomHandlerService.handle_conversation_admin_snoozed(
            intercom_workspace_id=intercom_workspace_id,
            intercom_conversation=intercom_conversation,
            notified_at=notified_at,
        )
    if topic == IntercomWebhookTopic.conversation_admin_unsnoozed:
        SupportIntercomHandlerService.handle_conversation_admin_unsnoozed(
            intercom_workspace_id=intercom_workspace_id,
            intercom_conversation=intercom_conversation,
            notified_at=notified_at,
        )
    if topic == IntercomWebhookTopic.conversation_deleted:
        SupportIntercomHandlerService.handle_conversation_deleted(
            intercom_workspace_id=intercom_workspace_id,
            intercom_conversation=intercom_conversation,
            notified_at=notified_at,
        )
    if topic == IntercomWebhookTopic.conversation_user_replied:
        SupportIntercomHandlerService.handle_conversation_user_replied(
            intercom_workspace_id=intercom_workspace_id,
            intercom_conversation=intercom_conversation,
            notified_at=notified_at,
        )
    if topic == IntercomWebhookTopic.conversation_admin_replied:
        SupportIntercomHandlerService.handle_conversation_admin_replied(
            intercom_workspace_id=intercom_workspace_id,
            intercom_conversation=intercom_conversation,
            notified_at=notified_at,
        )
    if topic == IntercomWebhookTopic.conversation_part_tag_created:
        SupportIntercomHandlerService.handle_conversation_part_tag_created(
            intercom_workspace_id=intercom_workspace_id,
            intercom_conversation=intercom_conversation,
            notified_at=notified_at,
        )
    elif topic == IntercomWebhookTopic.conversation_rating_added:
        SupportIntercomHandlerService.handle_conversation_rating_added(
            intercom_workspace_id=intercom_workspace_id,
            intercom_conversation=intercom_conversation,
            notified_at=notified_at,
        )
    else:
        # There is no "delete tag" webhook topic in Intercom, so we
        # need to check and update tags no matter what
        # (except if we've already done it, as is the case when it's a tag topic)
        update_contact_request_tags_async(
            intercom_workspace_id=intercom_workspace_id,
            intercom_conversation=intercom_conversation,
            notified_at=notified_at,
        )

    # Subcomponent handlers for ALL topics

    AssignerIntercomHandlerService.handle_topic(
        intercom_workspace_id=intercom_workspace_id,
        intercom_conversation=intercom_conversation,
        topic=topic,
        notified_at=notified_at,
        delivery_attempts=delivery_attempts,
    )

    GrowthIntercomHandlerService.handle_topic(
        intercom_workspace_id=intercom_workspace_id,
        intercom_conversation=intercom_conversation,
        topic=topic,
    )

    current_session.commit()

support_service

SupportService

Contains static methods to interact with direct Support features

create_contact_request staticmethod
create_contact_request(
    app_id,
    app_user_id,
    legacy_conversation_context_id,
    legacy_conversation_context_app_id,
    intercom_workspace_id,
    source_type,
    intercom_conversation_id=None,
    classification_result=None,
    commit=True,
    id=None,
)

This allows creating a contact request. A Support contact request represents any request from a user to contact Care (whatever the medium, sync conversation, async conversation, callback, etc.)

Source code in components/support/public/services/support_service.py
@staticmethod
def create_contact_request(
    app_id: AppName,
    app_user_id: str | None,
    legacy_conversation_context_id: str | None,
    legacy_conversation_context_app_id: AppName,
    intercom_workspace_id: str,
    source_type: ContactRequestSourceType,
    intercom_conversation_id: str | None = None,
    classification_result: ClassificationResult | None = None,
    commit: bool = True,
    id: UUID | None = None,
) -> UUID:
    """
    This allows creating a contact request. A Support contact request represents any request from a user
    to contact Care (whatever the medium, sync conversation, async conversation, callback, etc.)
    """
    from components.support.internal.business_logic.actions.contact_request_actions import (
        create_contact_request,
    )

    contact_request = create_contact_request(
        id=id,
        app_id=app_id,
        app_user_id=app_user_id,
        legacy_conversation_context_id=legacy_conversation_context_id,
        legacy_conversation_context_app_id=legacy_conversation_context_app_id,
        intercom_conversation_id=intercom_conversation_id,
        intercom_workspace_id=intercom_workspace_id,
        source_type=source_type,
        classification_result=classification_result,
        commit=commit,
    )
    contact_request_id: UUID = contact_request.id
    return contact_request_id
create_duplicate_contact_request staticmethod
create_duplicate_contact_request(
    app_id,
    app_user_id,
    legacy_conversation_context_id,
    legacy_conversation_context_app_id,
    intercom_workspace_id,
    duplicated_from_context_id,
    source_type,
    intercom_conversation_id=None,
    classification_result=None,
    commit=True,
    id=None,
    flush=True,
)

This allows creating a contact request that links to the original contact request. A Support contact request represents any request from a user to contact Care (whatever the medium, sync conversation, async conversation, callback, etc.) Since we don't usually know the source type, we'll default to unknown

Source code in components/support/public/services/support_service.py
@staticmethod
def create_duplicate_contact_request(
    app_id: AppName,
    app_user_id: str | None,
    legacy_conversation_context_id: str,
    legacy_conversation_context_app_id: AppName,
    intercom_workspace_id: str,
    duplicated_from_context_id: UUID,
    source_type: ContactRequestSourceType | None,
    intercom_conversation_id: str | None = None,
    classification_result: ClassificationResult | None = None,
    commit: bool = True,
    id: UUID | None = None,
    flush: bool = True,
) -> UUID:
    """
    This allows creating a contact request that links to the original contact request. A Support contact request represents any request from a user
    to contact Care (whatever the medium, sync conversation, async conversation, callback, etc.)
    Since we don't usually know the source type, we'll default to unknown
    """
    from components.support.internal.business_logic.actions.contact_request_actions import (
        create_contact_request,
    )
    from components.support.internal.business_logic.queries.contact_request_queries import (
        get_contact_request_by_legacy_context_id,
    )

    duplicate_contact_request = get_contact_request_by_legacy_context_id(
        duplicated_from_context_id
    )
    contact_request = create_contact_request(
        id=id,
        app_id=app_id,
        app_user_id=app_user_id,
        legacy_conversation_context_id=legacy_conversation_context_id,
        legacy_conversation_context_app_id=legacy_conversation_context_app_id,
        intercom_conversation_id=intercom_conversation_id,
        intercom_workspace_id=intercom_workspace_id,
        source_type=source_type
        if source_type
        else ContactRequestSourceType.unknown,
        duplicated_from_id=duplicate_contact_request.id
        if duplicate_contact_request
        else None,
        classification_result=classification_result,
        commit=commit,
        flush=flush,
    )
    contact_request_id: UUID = contact_request.id
    return contact_request_id
get_contact_request_source_type_from_context staticmethod
get_contact_request_source_type_from_context(
    legacy_conversation_context_id,
)

This method allows us to get the contact request source type from an existing legacy context.

Source code in components/support/public/services/support_service.py
@staticmethod
def get_contact_request_source_type_from_context(
    legacy_conversation_context_id: str,
) -> ContactRequestSourceType:
    """
    This method allows us to get the contact request source type from an existing legacy context.
    """
    return get_app_dependency().get_contact_request_source_type_from_context(
        legacy_conversation_context_id
    )
get_contact_request_with_tags_and_intercom_state staticmethod
get_contact_request_with_tags_and_intercom_state(
    intercom_conversation_id, intercom_workspace_id
)

This method allows returning the contact request joined with tags and state in one query

Source code in components/support/public/services/support_service.py
@staticmethod
def get_contact_request_with_tags_and_intercom_state(
    intercom_conversation_id: str, intercom_workspace_id: str
) -> ContactRequestWithTagsAndIntercomStateEntity | None:
    """
    This method allows returning the contact request joined with tags and state in one query
    """
    return get_contact_request_by_intercom_conversation(
        intercom_conversation_id=intercom_conversation_id,
        intercom_workspace_id=intercom_workspace_id,
    )
get_support_opening_status staticmethod
get_support_opening_status(app_user_id, role)

Get the support status for a given profile

Source code in components/support/public/services/support_service.py
@staticmethod
def get_support_opening_status(
    app_user_id: str | None,
    role: ContactRoleType | None,
) -> SupportAvailabilityDetails:
    """
    Get the support status for a given profile
    """
    return get_support_opening_status(app_user_id=app_user_id, role=role)
handle_new_conversation staticmethod
handle_new_conversation(
    legacy_conversation_context_id,
    legacy_conversation_context_app_id,
    intercom_conversation_id,
    intercom_workspace_id,
)

This method handles a new conversation from Intercom (either via a webhook call or via a rule calling an endpoint to tell us a conversation was created - in the latter case we need to get the Intercom conversation from their API and pass it to this method).

Source code in components/support/public/services/support_service.py
@staticmethod
def handle_new_conversation(
    legacy_conversation_context_id: str,
    legacy_conversation_context_app_id: AppName,
    intercom_conversation_id: str,
    intercom_workspace_id: str,
) -> None:
    """
    This method handles a new conversation from Intercom (either via a webhook call or via a rule calling an endpoint
    to tell us a conversation was created - in the latter case we need to get the Intercom conversation from their API
    and pass it to this method).
    """
    from components.support.internal.business_logic.actions.contact_request_actions import (
        handle_new_conversation_for_contact_request,
    )

    handle_new_conversation_for_contact_request(
        legacy_conversation_context_id=legacy_conversation_context_id,
        legacy_conversation_context_app_id=legacy_conversation_context_app_id,
        intercom_conversation_id=intercom_conversation_id,
        intercom_workspace_id=intercom_workspace_id,
    )
has_contact_request_been_processed_as_new_conversation staticmethod
has_contact_request_been_processed_as_new_conversation(
    intercom_conversation_id, intercom_workspace_id
)

This method checks if a contact request has been processed as a new conversation.

Source code in components/support/public/services/support_service.py
@staticmethod
def has_contact_request_been_processed_as_new_conversation(
    intercom_conversation_id: str,
    intercom_workspace_id: str,
) -> bool:
    """
    This method checks if a contact request has been processed as a new conversation.
    """
    contact_request = get_contact_request_by_intercom_conversation(
        intercom_workspace_id=intercom_workspace_id,
        intercom_conversation_id=intercom_conversation_id,
    )
    return (
        contact_request.has_been_processed_as_new_conversation
        if contact_request
        else False
    )
is_global_intercom_webhook_active staticmethod
is_global_intercom_webhook_active(app_name)

Returns true if the global intercom webhook endpoints feature flag is active for the current app.

Source code in components/support/public/services/support_service.py
@staticmethod
def is_global_intercom_webhook_active(app_name: AppName) -> bool:
    """
    Returns true if the global intercom webhook endpoints feature flag is active for
    the current app.
    """
    from components.support.internal.business_logic.queries.intercom_webhooks_queries import (
        is_global_intercom_webhook_active as _is_global_intercom_webhook_active,
    )

    return _is_global_intercom_webhook_active(app_name=app_name)
is_global_intercom_webhook_fallback_active staticmethod
is_global_intercom_webhook_fallback_active(app_name)

Returns true if the global intercom webhook endpoints feature flag is active for the current app.

Source code in components/support/public/services/support_service.py
@staticmethod
def is_global_intercom_webhook_fallback_active(app_name: AppName) -> bool:
    """
    Returns true if the global intercom webhook endpoints feature flag is active for
    the current app.
    """
    from components.support.internal.business_logic.actions.intercom_fallback.process_new_conversation_fallback import (
        is_fallback_command_active as _is_fallback_command_active,
    )

    return _is_fallback_command_active(app_name=app_name)
is_global_intercom_webhook_handlers_enabled staticmethod
is_global_intercom_webhook_handlers_enabled(app_name)

This method checks if the global intercom webhook handlers are enabled. To be used in webhook endpoints before calling the handlers.

Source code in components/support/public/services/support_service.py
@staticmethod
def is_global_intercom_webhook_handlers_enabled(app_name: AppName) -> bool:
    """
    This method checks if the global intercom webhook handlers are enabled. To be used in webhook endpoints
    before calling the handlers.
    """
    from shared.feature_flags.client import (
        bool_feature_flag,
        get_anonymous_user_context_data,
    )

    feature_flag_key = None

    if app_name == AppName.ALAN_FR:
        feature_flag_key = "killswitch-fr-backend-global-intercom-webhook-handlers"
    elif app_name == AppName.ALAN_BE:
        feature_flag_key = "killswitch-be-backend-global-intercom-webhook-handlers"
    elif app_name == AppName.ALAN_ES:
        feature_flag_key = "killswitch-es-backend-global-intercom-webhook-handlers"
    elif app_name == AppName.ALAN_CA:
        feature_flag_key = "killswitch-ca-backend-global-intercom-webhook-handlers"

    if not feature_flag_key:
        return False

    return bool_feature_flag(
        feature_flag_key=feature_flag_key,
        context_data=get_anonymous_user_context_data(),
        default_value=False,
    )
is_global_snooze_cron_active staticmethod
is_global_snooze_cron_active(app_name)

Controls which side (legacy vs global) runs cancel-on-event cron jobs.

When active: global action runs (pre-sync legacy→global, cancel, post-sync global→legacy). When inactive: legacy cron handles everything, global action is a no-op.

Source code in components/support/public/services/support_service.py
@staticmethod
def is_global_snooze_cron_active(app_name: AppName) -> bool:
    """
    Controls which side (legacy vs global) runs cancel-on-event cron jobs.

    When active: global action runs (pre-sync legacy→global, cancel, post-sync global→legacy).
    When inactive: legacy cron handles everything, global action is a no-op.
    """
    from components.support.subcomponents.snooze.internal.queries.snooze_feature_flag_queries import (
        is_global_snooze_cron_active as _is_global_snooze_cron_active,
    )

    return _is_global_snooze_cron_active(app_name=app_name)
is_global_snooze_webhooks_active staticmethod
is_global_snooze_webhooks_active(app_name)

Controls which side (legacy vs global) performs Intercom API calls for snooze webhooks.

When active: global handler makes Intercom calls, legacy does DB writes only. When inactive: legacy handler makes Intercom calls, global does DB writes only.

Source code in components/support/public/services/support_service.py
@staticmethod
def is_global_snooze_webhooks_active(app_name: AppName) -> bool:
    """
    Controls which side (legacy vs global) performs Intercom API calls for snooze webhooks.

    When active: global handler makes Intercom calls, legacy does DB writes only.
    When inactive: legacy handler makes Intercom calls, global does DB writes only.
    """
    from components.support.subcomponents.snooze.internal.queries.snooze_feature_flag_queries import (
        is_global_snooze_webhooks_active as _is_global_snooze_webhooks_active,
    )

    return _is_global_snooze_webhooks_active(app_name=app_name)
is_support_open staticmethod
is_support_open()

Check if support is currently open

Source code in components/support/public/services/support_service.py
@staticmethod
def is_support_open() -> bool:
    """
    Check if support is currently open
    """
    return is_support_open_on(time_to_check=None).is_open
sync_assignment_from_legacy_data staticmethod
sync_assignment_from_legacy_data(
    intercom_workspace_id,
    intercom_conversation_id,
    legacy_conversation_backlog,
    legacy_conversation_tags,
    legacy_conversation_assignments,
)

Sync data related to conversation assignment / rejection / move with legacy

Source code in components/support/public/services/support_service.py
@staticmethod
def sync_assignment_from_legacy_data(
    intercom_workspace_id: str,
    intercom_conversation_id: str,
    legacy_conversation_backlog: LegacyConversationBacklog,
    legacy_conversation_tags: list[LegacyConversationTag],
    legacy_conversation_assignments: list[LegacyConversationAssignment],
) -> None:
    """
    Sync data related to conversation assignment / rejection / move with legacy
    """
    return sync_contact_request_assignment_from_legacy_data(
        intercom_workspace_id=intercom_workspace_id,
        intercom_conversation_id=intercom_conversation_id,
        legacy_conversation_backlog=legacy_conversation_backlog,
        legacy_conversation_tags=legacy_conversation_tags,
        legacy_conversation_assignments=legacy_conversation_assignments,
    )
update_contact_request_classification_data_from_context staticmethod
update_contact_request_classification_data_from_context(
    legacy_conversation_context_id, classification_data
)

This method gets the contact request from the context, then updates it with classification data.

Source code in components/support/public/services/support_service.py
@staticmethod
def update_contact_request_classification_data_from_context(
    legacy_conversation_context_id: UUID,
    classification_data: ClassificationResult | None,
) -> None:
    """
    This method gets the contact request from the context, then updates it with classification data.
    """
    from components.support.internal.business_logic.actions.contact_request_actions import (
        update_classification_result_for_contact_request,
    )

    update_classification_result_for_contact_request(
        legacy_conversation_context_id=legacy_conversation_context_id,
        classification_data=classification_data,
    )
update_global_snooze_sequence_legacy_id staticmethod
update_global_snooze_sequence_legacy_id(
    global_sequence_id, legacy_sequence_id, commit=True
)

Sets the legacy_snooze_sequence_id on a support.snooze_sequence

Source code in components/support/public/services/support_service.py
@staticmethod
def update_global_snooze_sequence_legacy_id(
    global_sequence_id: UUID, legacy_sequence_id: UUID, commit: bool = True
) -> None:
    """
    Sets the legacy_snooze_sequence_id on a support.snooze_sequence
    """
    from components.support.subcomponents.snooze.internal.compatibility.actions.compat_sync_helpers import (
        update_global_snooze_sequence_legacy_id as action_update_global_snooze_sequence_legacy_id,
    )

    action_update_global_snooze_sequence_legacy_id(
        global_sequence_id=global_sequence_id,
        legacy_sequence_id=legacy_sequence_id,
        commit=commit,
    )

components.support.public.support_app_group

support module-attribute

support = AppGroup('support')