From 28f8cf842bc61448ff185605341d591815ab071f Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Mon, 9 Mar 2026 21:11:19 +0100 Subject: [PATCH 1/4] fix: gate discord role apply on linked contact --- .../src/five08/discord_bot/cogs/crm.py | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) 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..b79e4bb 100644 --- a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py +++ b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py @@ -1269,11 +1269,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: @@ -1284,6 +1285,13 @@ async def callback(self, interaction: discord.Interaction) -> None: ) return + if not view.can_apply_discord_roles: + await interaction.response.send_message( + "❌ Discord roles can only be applied after linking this contact to a Discord user.", + ephemeral=True, + ) + return + target_user_id_raw: str | None = None def _audit_apply_roles_event( @@ -1605,6 +1613,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 +1624,7 @@ 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 = can_apply_discord_roles self.discord_role_suggestions = list( dict.fromkeys(discord_role_suggestions or []) ) @@ -1637,7 +1647,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 +3679,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 +3702,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 +3913,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 +3956,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 +3996,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 +4042,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( From 91728db2b6169ce22b518f74a02410f7c6c05a72 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Mon, 9 Mar 2026 21:15:39 +0100 Subject: [PATCH 2/4] Fix inferred discord role-apply state --- apps/discord_bot/src/five08/discord_bot/cogs/crm.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 b79e4bb..ecdd0f2 100644 --- a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py +++ b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py @@ -1624,7 +1624,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 = can_apply_discord_roles + 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 []) ) From 2892fbd92cb3ccf77679ba59f269b1b534c7864c Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Mon, 9 Mar 2026 21:22:40 +0100 Subject: [PATCH 3/4] Preserve discord role apply deny audit and gating --- .../src/five08/discord_bot/cogs/crm.py | 23 ++++-- tests/unit/test_crm.py | 81 +++++++++++++++++++ 2 files changed, 96 insertions(+), 8 deletions(-) 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 ecdd0f2..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) @@ -1285,13 +1287,6 @@ async def callback(self, interaction: discord.Interaction) -> None: ) return - if not view.can_apply_discord_roles: - await interaction.response.send_message( - "❌ Discord roles can only be applied after linking this contact to a Discord user.", - ephemeral=True, - ) - return - target_user_id_raw: str | None = None def _audit_apply_roles_event( @@ -1314,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.", diff --git a/tests/unit/test_crm.py b/tests/unit/test_crm.py index 7a2bc7d..1bb9a01 100644 --- a/tests/unit/test_crm.py +++ b/tests/unit/test_crm.py @@ -757,6 +757,26 @@ async def test_edit_discord_roles_modal_prepopulates_values(self, crm_cog): assert modal.discord_roles_input.default == "Backend\nOperations" + 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 +789,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 +813,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 From d462f408285d6865dc5b423e32784d61d2902639 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Mon, 9 Mar 2026 21:25:37 +0100 Subject: [PATCH 4/4] Fix async context for view-disabled state test --- tests/unit/test_crm.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_crm.py b/tests/unit/test_crm.py index 1bb9a01..93fb294 100644 --- a/tests/unit/test_crm.py +++ b/tests/unit/test_crm.py @@ -757,7 +757,8 @@ async def test_edit_discord_roles_modal_prepopulates_values(self, crm_cog): assert modal.discord_roles_input.default == "Backend\nOperations" - def test_apply_discord_roles_button_disabled_without_linked_discord_user( + @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."""