diff --git a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py index 18694e2..f4a89fc 100644 --- a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py +++ b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py @@ -1023,7 +1023,9 @@ async def on_submit(self, interaction: discord.Interaction) -> None: self.confirmation_view.discord_role_suggestions = normalized for item in self.confirmation_view.children: if isinstance(item, ResumeApplyDiscordRolesButton): - item.disabled = not bool(normalized) + item.disabled = not ( + self.confirmation_view.can_apply_discord_roles and bool(normalized) + ) if normalized: count = len(normalized) @@ -1269,11 +1271,12 @@ async def callback(self, interaction: discord.Interaction) -> None: class ResumeApplyDiscordRolesButton(discord.ui.Button["ResumeUpdateConfirmationView"]): """Button that applies suggested Discord roles to the linked member.""" - def __init__(self) -> None: + def __init__(self, *, disabled: bool = False) -> None: super().__init__( label="Apply Discord Roles", style=discord.ButtonStyle.success, custom_id="resume_apply_discord_roles", + disabled=disabled, ) async def callback(self, interaction: discord.Interaction) -> None: @@ -1306,6 +1309,18 @@ def _audit_apply_roles_event( resource_id=view.contact_id, ) + if not view.can_apply_discord_roles: + _audit_apply_roles_event( + "denied", + "missing_linked_user", + {"reason": "button_disabled_for_role_application"}, + ) + await interaction.response.send_message( + "❌ Discord roles can only be applied after linking this contact to a Discord user.", + ephemeral=True, + ) + return + if not interaction.guild: await interaction.response.send_message( "❌ Discord roles can only be managed inside a server.", @@ -1605,6 +1620,7 @@ def __init__( parsed_seniority: str | None = None, discord_role_suggestions: list[str] | None = None, discord_role_target_user_id: str | None = None, + can_apply_discord_roles: bool = False, ) -> None: super().__init__(timeout=300) self.crm_cog = crm_cog @@ -1615,6 +1631,11 @@ def __init__( self.link_discord = link_discord self.parsed_seniority = parsed_seniority self.discord_role_target_user_id = discord_role_target_user_id + self.can_apply_discord_roles = bool( + can_apply_discord_roles + or discord_role_target_user_id + or (isinstance(link_discord, dict) and bool(link_discord.get("user_id"))) + ) self.discord_role_suggestions = list( dict.fromkeys(discord_role_suggestions or []) ) @@ -1637,7 +1658,9 @@ def __init__( self.add_item(ResumeEditRolesButton()) if self.discord_role_suggestions: self.add_item(ResumeEditDiscordRolesButton()) - self.add_item(ResumeApplyDiscordRolesButton()) + self.add_item( + ResumeApplyDiscordRolesButton(disabled=not self.can_apply_discord_roles) + ) self.add_item(ResumeEditLocationButton()) def _set_seniority_override(self, value: str) -> str: @@ -3667,6 +3690,7 @@ def _build_role_suggestions_embed( locality_roles: list[str] | None = None, extracted_profile: dict[str, Any] | None = None, current_discord_roles: list[str] | None = None, + can_apply_discord_roles: bool = False, ) -> discord.Embed | None: """Build a separate embed suggesting Discord roles to add based on resume data. @@ -3689,6 +3713,16 @@ def _build_role_suggestions_embed( description=f"Roles to **add** for **{contact_name}** based on resume — never remove existing roles.", color=0x57F287, ) + if not can_apply_discord_roles: + embed.add_field( + name="🔒 Link required", + value=( + "This contact is not currently linked to a Discord user, so suggested roles " + "cannot be applied automatically. Link them first in CRM or use " + "`/link-discord-user`." + ), + inline=False, + ) if technical: embed.add_field( @@ -3890,6 +3924,7 @@ async def _run_resume_extract_and_preview( role_suggestions_embed: discord.Embed | None = None suggested_discord_roles: list[str] = [] discord_role_target_user_id: str | None = None + can_apply_discord_roles = False if action_name == "crm.reprocess_resume" or ( action_name == "crm.upload_resume" and link_member ): @@ -3932,10 +3967,12 @@ async def _run_resume_extract_and_preview( suggested_discord_roles = list( dict.fromkeys(technical_suggestions + locality_suggestions) ) + can_apply_discord_roles = bool(discord_role_target_user_id or link_member) role_suggestions_embed = self._build_role_suggestions_embed( contact_name=contact_name, technical_roles=technical_suggestions, locality_roles=locality_suggestions, + can_apply_discord_roles=can_apply_discord_roles, ) if not proposed_updates and not link_member and not parsed_seniority: @@ -3970,6 +4007,7 @@ async def _run_resume_extract_and_preview( parsed_seniority=parsed_seniority, discord_role_suggestions=suggested_discord_roles, discord_role_target_user_id=discord_role_target_user_id, + can_apply_discord_roles=can_apply_discord_roles, ) if action_name != "crm.reprocess_resume": self._audit_command( @@ -4015,6 +4053,7 @@ async def _run_resume_extract_and_preview( parsed_seniority=parsed_seniority, discord_role_suggestions=suggested_discord_roles, discord_role_target_user_id=discord_role_target_user_id, + can_apply_discord_roles=can_apply_discord_roles, ) if action_name != "crm.reprocess_resume": self._audit_command( diff --git a/tests/unit/test_crm.py b/tests/unit/test_crm.py index 7a2bc7d..93fb294 100644 --- a/tests/unit/test_crm.py +++ b/tests/unit/test_crm.py @@ -757,6 +757,27 @@ async def test_edit_discord_roles_modal_prepopulates_values(self, crm_cog): assert modal.discord_roles_input.default == "Backend\nOperations" + @pytest.mark.asyncio + async def test_apply_discord_roles_button_disabled_without_linked_discord_user( + self, crm_cog + ): + """Apply button should be disabled if no Discord link target is available.""" + view = ResumeUpdateConfirmationView( + crm_cog=crm_cog, + requester_id=123, + contact_id="contact-1", + contact_name="Test User", + proposed_updates={}, + discord_role_suggestions=["Backend", "Operations"], + ) + + apply_button = next( + child + for child in view.children + if isinstance(child, ResumeApplyDiscordRolesButton) + ) + assert apply_button.disabled is True + @pytest.mark.asyncio async def test_edit_discord_roles_modal_submit_updates_suggested_roles( self, crm_cog @@ -769,6 +790,7 @@ async def test_edit_discord_roles_modal_submit_updates_suggested_roles( contact_name="Test User", proposed_updates={}, discord_role_suggestions=["Backend"], + discord_role_target_user_id="1001", ) modal = ResumeEditDiscordRolesModal(confirmation_view=view) modal.discord_roles_input._value = ( @@ -792,6 +814,66 @@ async def test_edit_discord_roles_modal_submit_updates_suggested_roles( ephemeral=True, ) + @pytest.mark.asyncio + async def test_edit_discord_roles_modal_submit_keeps_apply_disabled_without_link( + self, crm_cog + ): + """Apply Discord Roles remains disabled until the contact is linked.""" + view = ResumeUpdateConfirmationView( + crm_cog=crm_cog, + requester_id=123, + contact_id="contact-1", + contact_name="Test User", + proposed_updates={}, + discord_role_suggestions=["Backend"], + ) + modal = ResumeEditDiscordRolesModal(confirmation_view=view) + modal.discord_roles_input._value = "Backend\nOperations" + interaction = AsyncMock() + interaction.response = AsyncMock() + interaction.response.send_message = AsyncMock() + + await modal.on_submit(interaction) + + apply_button = next( + child + for child in view.children + if isinstance(child, ResumeApplyDiscordRolesButton) + ) + assert apply_button.disabled is True + interaction.response.send_message.assert_called_once_with( + "✅ Discord roles updated to 2 roles.", + ephemeral=True, + ) + + def test_build_role_suggestions_embed_marks_link_required_when_not_linked( + self, crm_cog + ): + """Role suggestions embed should include a link required callout when role apply is blocked.""" + embed = crm_cog._build_role_suggestions_embed( + contact_name="Test User", + technical_roles=["Backend"], + locality_roles=["APAC"], + can_apply_discord_roles=False, + ) + + assert embed is not None + assert any(field.name == "🔒 Link required" for field in embed.fields) + + def test_build_role_suggestions_embed_hides_link_required_when_linked( + self, crm_cog + ): + """Role suggestions embed should hide link required callout when application is enabled.""" + embed = crm_cog._build_role_suggestions_embed( + contact_name="Test User", + technical_roles=["Backend"], + locality_roles=["APAC"], + can_apply_discord_roles=True, + ) + + assert embed is not None + assert all(field.name != "🔒 Link required" for field in embed.fields) + @pytest.mark.asyncio async def test_apply_discord_roles_button_applies_roles_and_reports_results( self, crm_cog