From 26a90f07d17eb22696cf35ef33deae86ddea6619 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Sat, 6 Jun 2026 10:29:09 +0800 Subject: [PATCH 1/4] Add member agreement contact selection --- .../src/five08/discord_bot/cogs/crm.py | 323 ++++++++++++++---- tests/unit/test_crm.py | 84 +++++ 2 files changed, 336 insertions(+), 71 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 9b80d12d..00df88f1 100644 --- a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py +++ b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py @@ -711,6 +711,108 @@ async def on_timeout(self) -> None: ) +class MemberAgreementSelectionButton(discord.ui.Button["MemberAgreementSelectionView"]): + """Button for selecting a contact to send the member agreement to.""" + + def __init__(self, contact: dict[str, Any], requester_id: int) -> None: + contact_name = str(contact.get("name", "Unknown")) + label = contact_name[:80] if len(contact_name) > 80 else contact_name + super().__init__(style=discord.ButtonStyle.primary, label=label, emoji="šŸ“„") + self.contact = contact + self.requester_id = requester_id + + async def callback(self, interaction: discord.Interaction) -> None: + """Handle contact selection and send the member agreement.""" + try: + if not self.view: + await interaction.response.send_message( + "āŒ View not found.", + ephemeral=True, + ) + return + if interaction.user.id != self.requester_id: + await interaction.response.send_message( + "āŒ Only the command requester can confirm this action.", + ephemeral=True, + ) + return + + await interaction.response.defer(ephemeral=True) + await self.view.crm_cog._send_member_agreement_for_contact_flow( + interaction=interaction, + contact=self.contact, + search_term=self.view.search_term, + ) + + for item in self.view.children: + if isinstance(item, discord.ui.Button): + item.disabled = True + + if interaction.message: + try: + await interaction.message.edit(view=self.view) + except discord.NotFound: + pass + except discord.HTTPException as exc: + logger.warning( + "Failed to update send_member_agreement selection view: %s", + exc, + ) + except Exception as exc: + logger.error("Error in send_member_agreement selection callback: %s", exc) + await interaction.followup.send( + "āŒ An error occurred while handling the selection.", + ephemeral=True, + ) + + +class MemberAgreementSelectionView(discord.ui.View): + """View containing contact selection buttons for member agreements.""" + + def __init__( + self, + crm_cog: "CRMCog", + requester_id: int, + search_term: str, + ) -> None: + super().__init__(timeout=300) + self.crm_cog = crm_cog + self.requester_id = requester_id + self.search_term = search_term + self._message: discord.Message | None = None + + def add_contact_button(self, contact: dict[str, Any]) -> None: + """Add a contact selection button.""" + if len(self.children) >= 5: + return + self.add_item( + MemberAgreementSelectionButton( + contact=contact, + requester_id=self.requester_id, + ) + ) + + def set_message(self, message: discord.Message | None) -> None: + """Store the sent message so timeout can disable its controls.""" + self._message = message + + async def on_timeout(self) -> None: + """Disable controls when the selection times out and update the message.""" + for item in self.children: + if isinstance(item, discord.ui.Button): + item.disabled = True + if self._message: + try: + await self._message.edit(view=self) + except discord.NotFound: + pass + except discord.HTTPException as exc: + logger.warning( + "Failed to disable send_member_agreement selection view: %s", + exc, + ) + + class MarkIdVerifiedSelectionButton(discord.ui.Button["MarkIdVerifiedSelectionView"]): """Button for selecting a contact to mark ID verification on.""" @@ -8669,6 +8771,50 @@ async def _show_invite_outline_user_contact_choices( ) view.set_message(message) + async def _show_send_member_agreement_contact_choices( + self, + interaction: discord.Interaction, + *, + search_term: str, + contacts: list[dict[str, Any]], + ) -> None: + """Show contact choices for the member agreement command.""" + embed = discord.Embed( + title="šŸ” Multiple Contacts Found", + description=( + f"Found {len(contacts)} contacts for `{search_term}`. " + "Select the correct person to send the member agreement." + ), + color=0xFFA500, + ) + view = MemberAgreementSelectionView( + crm_cog=self, + requester_id=interaction.user.id, + search_term=search_term, + ) + + for i, contact in enumerate(contacts[:5], 1): + name = contact.get("name", "Unknown") + email = self._contact_preferred_email(contact) or "No email" + contact_id = contact.get("id", "") + contact_info = f"šŸ“§ {email}\nšŸ†” ID: `{contact_id}`" + embed.add_field(name=f"{i}. {name}", value=contact_info, inline=True) + view.add_contact_button(contact) + + embed.add_field( + name="šŸ’” Tip", + value="Select the contact button to continue, or rerun with a more specific term.", + inline=False, + ) + + message = await interaction.followup.send( + embed=embed, + view=view, + ephemeral=True, + wait=True, + ) + view.set_message(message) + @app_commands.command( name="mark-id-verified", description="Mark a contact as ID verified (Admin only).", @@ -9009,82 +9155,22 @@ async def invite_outline_user( ephemeral=True, ) - @app_commands.command( - name="send-member-agreement", - description="Send the member agreement for signing to a CRM contact.", - ) - @app_commands.describe( - search_term="Email, 508 email, Discord username, name, or contact ID." - ) - @require_role("Steering Committee") - async def send_member_agreement( + async def _send_member_agreement_for_contact_flow( self, + *, interaction: discord.Interaction, + contact: dict[str, Any], search_term: str, ) -> None: - """Send the member agreement to a contact via DocuSeal.""" - try: - await interaction.response.defer(ephemeral=True) - - contacts = await self._search_contacts_for_lookup( - search_term, - select=( - "id,name,emailAddress,c508Email,cDiscordUsername," - f"{MEMBER_AGREEMENT_SIGNED_AT_FIELD}" - ), - include_discord_user_search=True, - ) - if not contacts: - self._audit_command( - interaction=interaction, - action="crm.send_member_agreement", - result="success", - metadata={"search_term": search_term, "contacts_found": 0}, - ) - await interaction.followup.send( - f"āŒ No contact found for: `{search_term}`", - ephemeral=True, - ) - return - - if len(contacts) > 1: - lines: list[str] = [] - for contact in contacts[:5]: - contact_name = str(contact.get("name") or "Unknown") - contact_id = str(contact.get("id") or "") - display_email = self._contact_preferred_email(contact) or "No email" - lines.append( - f"- **{contact_name}** (`{contact_id}`) - `{display_email}`" - ) - suffix = ( - f"\n...and {len(contacts) - 5} more." if len(contacts) > 5 else "" - ) - self._audit_command( - interaction=interaction, - action="crm.send_member_agreement", - result="success", - metadata={ - "search_term": search_term, - "contacts_found": len(contacts), - "requires_selection": True, - }, - ) - await interaction.followup.send( - "āš ļø Multiple contacts found. Please refine your search:\n" - + "\n".join(lines) - + suffix, - ephemeral=True, - ) - return - - contact = contacts[0] - contact_id = str(contact.get("id") or "").strip() - contact_name = str(contact.get("name") or "Unknown").strip() or "Unknown" - contact_email = self._contact_preferred_email(contact) - signed_at = self._contact_text_value( - contact.get(MEMBER_AGREEMENT_SIGNED_AT_FIELD) - ) + """Send the member agreement for one selected CRM contact.""" + contact_id = str(contact.get("id") or "").strip() + contact_name = str(contact.get("name") or "Unknown").strip() or "Unknown" + contact_email = self._contact_preferred_email(contact) + signed_at = self._contact_text_value( + contact.get(MEMBER_AGREEMENT_SIGNED_AT_FIELD) + ) + try: if self._contact_has_signed_member_agreement(contact): self._audit_command( interaction=interaction, @@ -9188,6 +9274,101 @@ async def send_member_agreement( ephemeral=True, ) + @app_commands.command( + name="send-member-agreement", + description="Send the member agreement for signing to a CRM contact.", + ) + @app_commands.describe( + search_term="Email, 508 email, Discord username, name, or contact ID." + ) + @require_role("Steering Committee") + async def send_member_agreement( + self, + interaction: discord.Interaction, + search_term: str, + ) -> None: + """Send the member agreement to a contact via DocuSeal.""" + try: + await interaction.response.defer(ephemeral=True) + + contacts = await self._search_contacts_for_lookup( + search_term, + select=( + "id,name,emailAddress,c508Email,cDiscordUsername," + f"{MEMBER_AGREEMENT_SIGNED_AT_FIELD}" + ), + include_discord_user_search=True, + ) + if not contacts: + self._audit_command( + interaction=interaction, + action="crm.send_member_agreement", + result="success", + metadata={"search_term": search_term, "contacts_found": 0}, + ) + await interaction.followup.send( + f"āŒ No contact found for: `{search_term}`", + ephemeral=True, + ) + return + + if len(contacts) > 1: + self._audit_command( + interaction=interaction, + action="crm.send_member_agreement", + result="success", + metadata={ + "search_term": search_term, + "contacts_found": len(contacts), + "requires_selection": True, + }, + ) + await self._show_send_member_agreement_contact_choices( + interaction, + search_term=search_term, + contacts=contacts, + ) + return + + await self._send_member_agreement_for_contact_flow( + interaction=interaction, + contact=contacts[0], + search_term=search_term, + ) + except ValueError as exc: + logger.error("Member agreement command configuration/input error: %s", exc) + self._audit_command( + interaction=interaction, + action="crm.send_member_agreement", + result="error", + metadata={"search_term": search_term, "error": str(exc)}, + ) + await interaction.followup.send(f"āŒ {exc}", ephemeral=True) + except DocusealAPIError as exc: + logger.error("DocuSeal API error in send_member_agreement: %s", exc) + sanitized_error = self._sanitize_error_message_for_discord(exc) + self._audit_command( + interaction=interaction, + action="crm.send_member_agreement", + result="error", + metadata={"search_term": search_term, "error": sanitized_error}, + ) + await interaction.followup.send( + f"āŒ DocuSeal API error: {sanitized_error}", ephemeral=True + ) + except Exception as exc: + logger.error("Unexpected error in send_member_agreement: %s", exc) + self._audit_command( + interaction=interaction, + action="crm.send_member_agreement", + result="error", + metadata={"search_term": search_term, "error": str(exc)}, + ) + await interaction.followup.send( + "āŒ An unexpected error occurred while sending the member agreement.", + ephemeral=True, + ) + @app_commands.command( name="link-discord-user", description="Link a Discord user to a CRM contact (Steering Committee+ only)", diff --git a/tests/unit/test_crm.py b/tests/unit/test_crm.py index 443f383a..2d499230 100644 --- a/tests/unit/test_crm.py +++ b/tests/unit/test_crm.py @@ -13,6 +13,7 @@ from five08.discord_bot.cogs.crm import ( CRMCog, DiscordLinkOverwriteConfirmationView, + MemberAgreementSelectionView, ResumeButtonView, ResumeConfirmationView, ResumeCreateContactView, @@ -7625,6 +7626,89 @@ async def test_send_member_agreement_requires_contact_email( assert audit_kwargs["result"] == "denied" assert audit_kwargs["metadata"]["reason"] == "missing_email" + @pytest.mark.asyncio + async def test_send_member_agreement_shows_selection_view_for_multiple_contacts( + self, crm_cog, mock_interaction + ): + """Ambiguous contact searches should show buttons for sending to a match.""" + steering_role = Mock() + steering_role.name = "Steering Committee" + mock_interaction.user.id = 123 + mock_interaction.user.roles = [steering_role] + crm_cog._audit_command = Mock() + contacts = [ + { + "id": "crm-123", + "name": "Will Gutierrez", + "emailAddress": "will.gutierrez@gmail.com", + "cMemberAgreementSignedAt": None, + }, + { + "id": "crm-456", + "name": "Wilson Kao", + "emailAddress": "lairwaves5888@gmail.com", + "cMemberAgreementSignedAt": None, + }, + ] + crm_cog._search_contacts_for_lookup = AsyncMock(return_value=contacts) + crm_cog._create_member_agreement_submission_for_contact = AsyncMock() + + await crm_cog.send_member_agreement.callback(crm_cog, mock_interaction, "wil") + + crm_cog._create_member_agreement_submission_for_contact.assert_not_awaited() + kwargs = mock_interaction.followup.send.call_args.kwargs + assert kwargs["ephemeral"] is True + assert kwargs["wait"] is True + view = kwargs["view"] + assert isinstance(view, MemberAgreementSelectionView) + labels = [item.label for item in view.children if hasattr(item, "label")] + assert labels == ["Will Gutierrez", "Wilson Kao"] + audit_kwargs = crm_cog._audit_command.call_args.kwargs + assert audit_kwargs["metadata"]["requires_selection"] is True + + @pytest.mark.asyncio + async def test_member_agreement_selection_button_sends_selected_contact( + self, crm_cog + ): + """Clicking a member agreement selection button should send to that contact.""" + contact = { + "id": "crm-123", + "name": "Will Gutierrez", + "emailAddress": "will.gutierrez@gmail.com", + } + view = MemberAgreementSelectionView( + crm_cog=crm_cog, + requester_id=123, + search_term="wil", + ) + view.add_contact_button(contact) + button = view.children[0] + crm_cog._send_member_agreement_for_contact_flow = AsyncMock() + + interaction = AsyncMock() + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.followup = AsyncMock() + interaction.user = Mock() + interaction.user.id = 123 + interaction.message = AsyncMock() + interaction.message.edit = AsyncMock() + + await button.callback(interaction) + + crm_cog._send_member_agreement_for_contact_flow.assert_awaited_once_with( + interaction=interaction, + contact=contact, + search_term="wil", + ) + interaction.response.defer.assert_awaited_once_with(ephemeral=True) + assert all( + item.disabled + for item in view.children + if isinstance(item, discord.ui.Button) + ) + interaction.message.edit.assert_awaited_once_with(view=view) + @pytest.mark.asyncio async def test_send_member_agreement_search_includes_discord_username( self, crm_cog From 3269133e573138dae294c21804bae50baa93c3b9 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Sat, 6 Jun 2026 10:34:19 +0800 Subject: [PATCH 2/4] Prevent duplicate member agreement sends --- .../src/five08/discord_bot/cogs/crm.py | 39 ++++++++++++------ tests/unit/test_crm.py | 40 +++++++++++++++++++ 2 files changed, 67 insertions(+), 12 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 00df88f1..df15c1d9 100644 --- a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py +++ b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py @@ -736,18 +736,15 @@ async def callback(self, interaction: discord.Interaction) -> None: ephemeral=True, ) return + if not self.view.try_start_selection(): + await interaction.response.send_message( + "āš ļø This member agreement selection is already being processed.", + ephemeral=True, + ) + return await interaction.response.defer(ephemeral=True) - await self.view.crm_cog._send_member_agreement_for_contact_flow( - interaction=interaction, - contact=self.contact, - search_term=self.view.search_term, - ) - - for item in self.view.children: - if isinstance(item, discord.ui.Button): - item.disabled = True - + self.view.disable_controls() if interaction.message: try: await interaction.message.edit(view=self.view) @@ -758,6 +755,12 @@ async def callback(self, interaction: discord.Interaction) -> None: "Failed to update send_member_agreement selection view: %s", exc, ) + + await self.view.crm_cog._send_member_agreement_for_contact_flow( + interaction=interaction, + contact=self.contact, + search_term=self.view.search_term, + ) except Exception as exc: logger.error("Error in send_member_agreement selection callback: %s", exc) await interaction.followup.send( @@ -780,6 +783,7 @@ def __init__( self.requester_id = requester_id self.search_term = search_term self._message: discord.Message | None = None + self._selection_started = False def add_contact_button(self, contact: dict[str, Any]) -> None: """Add a contact selection button.""" @@ -796,11 +800,22 @@ def set_message(self, message: discord.Message | None) -> None: """Store the sent message so timeout can disable its controls.""" self._message = message - async def on_timeout(self) -> None: - """Disable controls when the selection times out and update the message.""" + def try_start_selection(self) -> bool: + """Mark this view as processing one selection if it has not started.""" + if self._selection_started: + return False + self._selection_started = True + return True + + def disable_controls(self) -> None: + """Disable all controls in the view.""" for item in self.children: if isinstance(item, discord.ui.Button): item.disabled = True + + async def on_timeout(self) -> None: + """Disable controls when the selection times out and update the message.""" + self.disable_controls() if self._message: try: await self._message.edit(view=self) diff --git a/tests/unit/test_crm.py b/tests/unit/test_crm.py index 2d499230..0eab15de 100644 --- a/tests/unit/test_crm.py +++ b/tests/unit/test_crm.py @@ -7709,6 +7709,46 @@ async def test_member_agreement_selection_button_sends_selected_contact( ) interaction.message.edit.assert_awaited_once_with(view=view) + @pytest.mark.asyncio + async def test_member_agreement_selection_button_rejects_duplicate_click( + self, crm_cog + ): + """A second member agreement selection should not send another submission.""" + contact = { + "id": "crm-123", + "name": "Will Gutierrez", + "emailAddress": "will.gutierrez@gmail.com", + } + view = MemberAgreementSelectionView( + crm_cog=crm_cog, + requester_id=123, + search_term="wil", + ) + view.add_contact_button(contact) + button = view.children[0] + crm_cog._send_member_agreement_for_contact_flow = AsyncMock() + assert view.try_start_selection() is True + + interaction = AsyncMock() + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.response.send_message = AsyncMock() + interaction.followup = AsyncMock() + interaction.user = Mock() + interaction.user.id = 123 + interaction.message = AsyncMock() + interaction.message.edit = AsyncMock() + + await button.callback(interaction) + + crm_cog._send_member_agreement_for_contact_flow.assert_not_awaited() + interaction.response.defer.assert_not_awaited() + interaction.response.send_message.assert_awaited_once_with( + "āš ļø This member agreement selection is already being processed.", + ephemeral=True, + ) + interaction.message.edit.assert_not_awaited() + @pytest.mark.asyncio async def test_send_member_agreement_search_includes_discord_username( self, crm_cog From a879d39aaea236dbd4de546cbff90e993c5b8e11 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Sat, 6 Jun 2026 10:48:02 +0800 Subject: [PATCH 3/4] Show signed status in agreement choices --- .../src/five08/discord_bot/cogs/crm.py | 34 +++++++++++---- tests/unit/test_crm.py | 41 +++++++++++++++++++ 2 files changed, 68 insertions(+), 7 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 df15c1d9..09097771 100644 --- a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py +++ b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py @@ -8812,23 +8812,43 @@ async def _show_send_member_agreement_contact_choices( name = contact.get("name", "Unknown") email = self._contact_preferred_email(contact) or "No email" contact_id = contact.get("id", "") - contact_info = f"šŸ“§ {email}\nšŸ†” ID: `{contact_id}`" + signed_at = self._contact_text_value( + contact.get(MEMBER_AGREEMENT_SIGNED_AT_FIELD) + ) + agreement_status = ( + f"āœ… Already signed: `{signed_at}`" + if signed_at + else "šŸ“ Not signed; send/resend allowed" + ) + contact_info = f"šŸ“§ {email}\nšŸ†” ID: `{contact_id}`\n{agreement_status}" embed.add_field(name=f"{i}. {name}", value=contact_info, inline=True) - view.add_contact_button(contact) + if not signed_at: + view.add_contact_button(contact) embed.add_field( name="šŸ’” Tip", - value="Select the contact button to continue, or rerun with a more specific term.", + value=( + "Select an unsigned contact button to continue. " + "Prior send requests are not tracked, so unsigned contacts can be resent." + ), inline=False, ) - message = await interaction.followup.send( + has_send_options = len(view.children) > 0 + if has_send_options: + message = await interaction.followup.send( + embed=embed, + view=view, + ephemeral=True, + wait=True, + ) + view.set_message(message) + return + + await interaction.followup.send( embed=embed, - view=view, ephemeral=True, - wait=True, ) - view.set_message(message) @app_commands.command( name="mark-id-verified", diff --git a/tests/unit/test_crm.py b/tests/unit/test_crm.py index 0eab15de..0cb16b06 100644 --- a/tests/unit/test_crm.py +++ b/tests/unit/test_crm.py @@ -7666,6 +7666,47 @@ async def test_send_member_agreement_shows_selection_view_for_multiple_contacts( audit_kwargs = crm_cog._audit_command.call_args.kwargs assert audit_kwargs["metadata"]["requires_selection"] is True + @pytest.mark.asyncio + async def test_send_member_agreement_marks_signed_contacts_without_buttons( + self, crm_cog, mock_interaction + ): + """Already signed ambiguous matches should be labeled and not selectable.""" + steering_role = Mock() + steering_role.name = "Steering Committee" + mock_interaction.user.id = 123 + mock_interaction.user.roles = [steering_role] + crm_cog._audit_command = Mock() + contacts = [ + { + "id": "crm-123", + "name": "Will Gutierrez", + "emailAddress": "will.gutierrez@gmail.com", + "cMemberAgreementSignedAt": None, + }, + { + "id": "crm-456", + "name": "Wilson Kao", + "emailAddress": "lairwaves5888@gmail.com", + "cMemberAgreementSignedAt": "2026-03-20 10:00:00", + }, + ] + crm_cog._search_contacts_for_lookup = AsyncMock(return_value=contacts) + crm_cog._create_member_agreement_submission_for_contact = AsyncMock() + + await crm_cog.send_member_agreement.callback(crm_cog, mock_interaction, "wil") + + kwargs = mock_interaction.followup.send.call_args.kwargs + view = kwargs["view"] + assert isinstance(view, MemberAgreementSelectionView) + labels = [item.label for item in view.children if hasattr(item, "label")] + assert labels == ["Will Gutierrez"] + embed = kwargs["embed"] + field_values = [field.value for field in embed.fields] + assert any("Not signed; send/resend allowed" in value for value in field_values) + assert any( + "Already signed: `2026-03-20 10:00:00`" in value for value in field_values + ) + @pytest.mark.asyncio async def test_member_agreement_selection_button_sends_selected_contact( self, crm_cog From 77cc5a993cbf133fa1605a03fff4f273e82149b9 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Sat, 6 Jun 2026 11:11:39 +0800 Subject: [PATCH 4/4] Address member agreement review feedback --- .../src/five08/discord_bot/cogs/crm.py | 82 +++++++- tests/unit/test_crm.py | 176 +++++++++++++++++- 2 files changed, 247 insertions(+), 11 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 09097771..7e5605e8 100644 --- a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py +++ b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py @@ -731,12 +731,34 @@ async def callback(self, interaction: discord.Interaction) -> None: ) return if interaction.user.id != self.requester_id: + self.view.crm_cog._audit_command_safe( + interaction=interaction, + action="crm.send_member_agreement", + result="denied", + metadata={ + "reason": "requester_mismatch", + "selected_contact_id": str(self.contact.get("id") or ""), + }, + resource_type="crm_contact", + resource_id=str(self.contact.get("id") or ""), + ) await interaction.response.send_message( "āŒ Only the command requester can confirm this action.", ephemeral=True, ) return if not self.view.try_start_selection(): + self.view.crm_cog._audit_command_safe( + interaction=interaction, + action="crm.send_member_agreement", + result="denied", + metadata={ + "reason": "selection_already_processing", + "selected_contact_id": str(self.contact.get("id") or ""), + }, + resource_type="crm_contact", + resource_id=str(self.contact.get("id") or ""), + ) await interaction.response.send_message( "āš ļø This member agreement selection is already being processed.", ephemeral=True, @@ -763,6 +785,19 @@ async def callback(self, interaction: discord.Interaction) -> None: ) except Exception as exc: logger.error("Error in send_member_agreement selection callback: %s", exc) + if self.view: + self.view.crm_cog._audit_command_safe( + interaction=interaction, + action="crm.send_member_agreement", + result="error", + metadata={ + "stage": "selection_callback", + "error": str(exc), + "selected_contact_id": str(self.contact.get("id") or ""), + }, + resource_type="crm_contact", + resource_id=str(self.contact.get("id") or ""), + ) await interaction.followup.send( "āŒ An error occurred while handling the selection.", ephemeral=True, @@ -8786,6 +8821,27 @@ async def _show_invite_outline_user_contact_choices( ) view.set_message(message) + def _member_agreement_choice_contacts( + self, contacts: list[dict[str, Any]] + ) -> list[dict[str, Any]]: + """Return up to five member-agreement choices, prioritizing unsigned contacts.""" + unsigned_contacts = [ + contact + for contact in contacts + if not self._contact_has_signed_member_agreement(contact) + ] + if not unsigned_contacts: + return contacts[:5] + + signed_contacts = [ + contact + for contact in contacts + if self._contact_has_signed_member_agreement(contact) + ] + choices = unsigned_contacts[:5] + choices.extend(signed_contacts[: max(0, 5 - len(choices))]) + return choices + async def _show_send_member_agreement_contact_choices( self, interaction: discord.Interaction, @@ -8808,7 +8864,14 @@ async def _show_send_member_agreement_contact_choices( search_term=search_term, ) - for i, contact in enumerate(contacts[:5], 1): + displayed_contacts = self._member_agreement_choice_contacts(contacts) + unsigned_count = sum( + 1 + for contact in contacts + if not self._contact_has_signed_member_agreement(contact) + ) + + for i, contact in enumerate(displayed_contacts, 1): name = contact.get("name", "Unknown") email = self._contact_preferred_email(contact) or "No email" contact_id = contact.get("id", "") @@ -8825,12 +8888,21 @@ async def _show_send_member_agreement_contact_choices( if not signed_at: view.add_contact_button(contact) + tip_value = ( + "Select an unsigned contact button to continue. " + "Prior send requests are not tracked, so unsigned contacts can be resent." + if unsigned_count + else "All matching contacts already signed. No send buttons are available." + ) + if len(contacts) > len(displayed_contacts): + tip_value += ( + "\nShowing up to 5 matches, prioritizing unsigned contacts. " + "Refine your search for omitted matches." + ) + embed.add_field( name="šŸ’” Tip", - value=( - "Select an unsigned contact button to continue. " - "Prior send requests are not tracked, so unsigned contacts can be resent." - ), + value=tip_value, inline=False, ) diff --git a/tests/unit/test_crm.py b/tests/unit/test_crm.py index 0cb16b06..ac6c9cb4 100644 --- a/tests/unit/test_crm.py +++ b/tests/unit/test_crm.py @@ -7640,13 +7640,13 @@ async def test_send_member_agreement_shows_selection_view_for_multiple_contacts( { "id": "crm-123", "name": "Will Gutierrez", - "emailAddress": "will.gutierrez@gmail.com", + "emailAddress": "will.gutierrez@example.com", "cMemberAgreementSignedAt": None, }, { "id": "crm-456", "name": "Wilson Kao", - "emailAddress": "lairwaves5888@gmail.com", + "emailAddress": "wilson.kao@example.com", "cMemberAgreementSignedAt": None, }, ] @@ -7680,13 +7680,13 @@ async def test_send_member_agreement_marks_signed_contacts_without_buttons( { "id": "crm-123", "name": "Will Gutierrez", - "emailAddress": "will.gutierrez@gmail.com", + "emailAddress": "will.gutierrez@example.com", "cMemberAgreementSignedAt": None, }, { "id": "crm-456", "name": "Wilson Kao", - "emailAddress": "lairwaves5888@gmail.com", + "emailAddress": "wilson.kao@example.com", "cMemberAgreementSignedAt": "2026-03-20 10:00:00", }, ] @@ -7707,6 +7707,83 @@ async def test_send_member_agreement_marks_signed_contacts_without_buttons( "Already signed: `2026-03-20 10:00:00`" in value for value in field_values ) + @pytest.mark.asyncio + async def test_send_member_agreement_prioritizes_unsigned_contact_after_first_five( + self, crm_cog, mock_interaction + ): + """Eligible unsigned matches after the first five should still be selectable.""" + steering_role = Mock() + steering_role.name = "Steering Committee" + mock_interaction.user.id = 123 + mock_interaction.user.roles = [steering_role] + crm_cog._audit_command = Mock() + contacts = [ + { + "id": f"signed-{index}", + "name": f"Signed Contact {index}", + "emailAddress": f"signed{index}@example.com", + "cMemberAgreementSignedAt": "2026-03-20 10:00:00", + } + for index in range(1, 6) + ] + contacts.append( + { + "id": "unsigned-6", + "name": "Unsigned Contact", + "emailAddress": "unsigned@example.com", + "cMemberAgreementSignedAt": None, + } + ) + crm_cog._search_contacts_for_lookup = AsyncMock(return_value=contacts) + + await crm_cog.send_member_agreement.callback(crm_cog, mock_interaction, "wil") + + kwargs = mock_interaction.followup.send.call_args.kwargs + view = kwargs["view"] + assert isinstance(view, MemberAgreementSelectionView) + labels = [item.label for item in view.children if hasattr(item, "label")] + assert labels == ["Unsigned Contact"] + embed = kwargs["embed"] + field_names = [field.name for field in embed.fields] + assert "1. Unsigned Contact" in field_names + tip = embed.fields[-1].value + assert "prioritizing unsigned contacts" in tip + + @pytest.mark.asyncio + async def test_send_member_agreement_all_signed_has_no_button_tip( + self, crm_cog, mock_interaction + ): + """All-signed ambiguous matches should not tell users to click buttons.""" + steering_role = Mock() + steering_role.name = "Steering Committee" + mock_interaction.user.id = 123 + mock_interaction.user.roles = [steering_role] + crm_cog._audit_command = Mock() + contacts = [ + { + "id": "crm-123", + "name": "Will Gutierrez", + "emailAddress": "will.gutierrez@example.com", + "cMemberAgreementSignedAt": "2026-03-20 10:00:00", + }, + { + "id": "crm-456", + "name": "Wilson Kao", + "emailAddress": "wilson.kao@example.com", + "cMemberAgreementSignedAt": "2026-03-21 10:00:00", + }, + ] + crm_cog._search_contacts_for_lookup = AsyncMock(return_value=contacts) + + await crm_cog.send_member_agreement.callback(crm_cog, mock_interaction, "wil") + + kwargs = mock_interaction.followup.send.call_args.kwargs + assert "view" not in kwargs + assert "wait" not in kwargs + assert kwargs["embed"].fields[-1].value == ( + "All matching contacts already signed. No send buttons are available." + ) + @pytest.mark.asyncio async def test_member_agreement_selection_button_sends_selected_contact( self, crm_cog @@ -7715,7 +7792,7 @@ async def test_member_agreement_selection_button_sends_selected_contact( contact = { "id": "crm-123", "name": "Will Gutierrez", - "emailAddress": "will.gutierrez@gmail.com", + "emailAddress": "will.gutierrez@example.com", } view = MemberAgreementSelectionView( crm_cog=crm_cog, @@ -7750,6 +7827,88 @@ async def test_member_agreement_selection_button_sends_selected_contact( ) interaction.message.edit.assert_awaited_once_with(view=view) + @pytest.mark.asyncio + async def test_member_agreement_selection_button_audits_requester_mismatch( + self, crm_cog + ): + """Non-requester button clicks should be denied and audited.""" + contact = { + "id": "crm-123", + "name": "Will Gutierrez", + "emailAddress": "will.gutierrez@example.com", + } + view = MemberAgreementSelectionView( + crm_cog=crm_cog, + requester_id=123, + search_term="wil", + ) + view.add_contact_button(contact) + button = view.children[0] + crm_cog._audit_command_safe = Mock() + crm_cog._send_member_agreement_for_contact_flow = AsyncMock() + + interaction = AsyncMock() + interaction.response = AsyncMock() + interaction.response.send_message = AsyncMock() + interaction.user = Mock() + interaction.user.id = 456 + + await button.callback(interaction) + + crm_cog._audit_command_safe.assert_called_once() + audit_kwargs = crm_cog._audit_command_safe.call_args.kwargs + assert audit_kwargs["result"] == "denied" + assert audit_kwargs["metadata"]["reason"] == "requester_mismatch" + assert audit_kwargs["resource_id"] == "crm-123" + crm_cog._send_member_agreement_for_contact_flow.assert_not_awaited() + interaction.response.send_message.assert_awaited_once_with( + "āŒ Only the command requester can confirm this action.", + ephemeral=True, + ) + + @pytest.mark.asyncio + async def test_member_agreement_selection_button_audits_callback_error( + self, crm_cog + ): + """Unexpected button callback errors should be audited.""" + contact = { + "id": "crm-123", + "name": "Will Gutierrez", + "emailAddress": "will.gutierrez@example.com", + } + view = MemberAgreementSelectionView( + crm_cog=crm_cog, + requester_id=123, + search_term="wil", + ) + view.add_contact_button(contact) + button = view.children[0] + crm_cog._audit_command_safe = Mock() + crm_cog._send_member_agreement_for_contact_flow = AsyncMock( + side_effect=RuntimeError("docuseal failed") + ) + + interaction = AsyncMock() + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.followup = AsyncMock() + interaction.followup.send = AsyncMock() + interaction.user = Mock() + interaction.user.id = 123 + interaction.message = None + + await button.callback(interaction) + + crm_cog._audit_command_safe.assert_called_once() + audit_kwargs = crm_cog._audit_command_safe.call_args.kwargs + assert audit_kwargs["result"] == "error" + assert audit_kwargs["metadata"]["stage"] == "selection_callback" + assert audit_kwargs["metadata"]["selected_contact_id"] == "crm-123" + interaction.followup.send.assert_awaited_once_with( + "āŒ An error occurred while handling the selection.", + ephemeral=True, + ) + @pytest.mark.asyncio async def test_member_agreement_selection_button_rejects_duplicate_click( self, crm_cog @@ -7758,7 +7917,7 @@ async def test_member_agreement_selection_button_rejects_duplicate_click( contact = { "id": "crm-123", "name": "Will Gutierrez", - "emailAddress": "will.gutierrez@gmail.com", + "emailAddress": "will.gutierrez@example.com", } view = MemberAgreementSelectionView( crm_cog=crm_cog, @@ -7767,6 +7926,7 @@ async def test_member_agreement_selection_button_rejects_duplicate_click( ) view.add_contact_button(contact) button = view.children[0] + crm_cog._audit_command_safe = Mock() crm_cog._send_member_agreement_for_contact_flow = AsyncMock() assert view.try_start_selection() is True @@ -7783,6 +7943,10 @@ async def test_member_agreement_selection_button_rejects_duplicate_click( await button.callback(interaction) crm_cog._send_member_agreement_for_contact_flow.assert_not_awaited() + crm_cog._audit_command_safe.assert_called_once() + audit_kwargs = crm_cog._audit_command_safe.call_args.kwargs + assert audit_kwargs["result"] == "denied" + assert audit_kwargs["metadata"]["reason"] == "selection_already_processing" interaction.response.defer.assert_not_awaited() interaction.response.send_message.assert_awaited_once_with( "āš ļø This member agreement selection is already being processed.",