Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions apps/discord_bot/src/five08/discord_bot/cogs/crm.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,85 @@ def add_contact_button(
self.add_item(button)


class MarkIdVerifiedOverwriteConfirmationView(discord.ui.View):
"""View for confirming overwrite of existing ID verification values."""

def __init__(
self,
crm_cog: "CRMCog",
interaction: discord.Interaction,
contact: dict[str, Any],
verified_by: str,
verified_at: str,
id_type: str | None,
) -> None:
super().__init__(timeout=300) # 5 minute timeout
self.crm_cog = crm_cog
self.original_interaction = interaction
self.contact = contact
self.verified_by = verified_by
self.verified_at = verified_at
self.id_type = id_type
self.requester_id = interaction.user.id

async def interaction_check(self, interaction: discord.Interaction) -> bool:
"""Allow only the original requester to confirm/cancel."""
if interaction.user.id != self.requester_id:
await interaction.response.send_message(
"❌ Only the command requester can confirm this action.",
ephemeral=True,
)
return False
return True

@discord.ui.button(label="Overwrite", style=discord.ButtonStyle.danger, emoji="⚠️")
async def confirm_overwrite(
self,
interaction: discord.Interaction,
button: discord.ui.Button["MarkIdVerifiedOverwriteConfirmationView"],
) -> None:
"""Overwrite existing verification metadata and continue."""
await interaction.response.defer(ephemeral=True)
await self.crm_cog._mark_id_verified_for_contact(
interaction=interaction,
contact=self.contact,
verified_by=self.verified_by,
verified_at=self.verified_at,
id_type=self.id_type,
allow_overwrite=True,
)
for item in self.children:
if isinstance(item, discord.ui.Button):
item.disabled = True

if interaction.message:
try:
await interaction.message.edit(view=self)
except discord.NotFound:
pass

@discord.ui.button(label="Cancel", style=discord.ButtonStyle.secondary, emoji="❌")
async def cancel_overwrite(
self,
interaction: discord.Interaction,
button: discord.ui.Button["MarkIdVerifiedOverwriteConfirmationView"],
) -> None:
"""Cancel overwrite and leave contact unchanged."""
await interaction.response.send_message(
"✅ ID verification overwrite cancelled. No changes were made.",
ephemeral=True,
)
for item in self.children:
if isinstance(item, discord.ui.Button):
item.disabled = True

if interaction.message:
try:
await interaction.message.edit(view=self)
except discord.NotFound:
pass


class ResumeConfirmationView(discord.ui.View):
"""View for confirming resume upload when duplicate is detected."""

Expand Down Expand Up @@ -2317,6 +2396,7 @@ async def _mark_id_verified_for_contact(
verified_by: str,
verified_at: str,
id_type: str | None,
allow_overwrite: bool = False,
) -> bool:
"""Persist ID verification metadata to CRM."""
contact_id = contact.get("id")
Expand All @@ -2336,6 +2416,87 @@ async def _mark_id_verified_for_contact(
await interaction.followup.send("❌ Contact ID not found.")
return False

try:
current_contact = self.espo_api.request("GET", f"Contact/{contact_id}")
except EspoAPIError as exc:
logger.error(
f"Failed to fetch contact before marking ID verification: {exc}"
)
self._audit_command(
interaction=interaction,
action="crm.mark_id_verified",
result="error",
metadata={
"contact_id": str(contact_id),
"verified_by": verified_by,
"verified_at": verified_at,
"error": "contact_lookup_failed",
},
resource_type="crm_contact",
resource_id=str(contact_id),
)
await interaction.followup.send(
"❌ Failed to load current verification data from CRM."
)
return False

existing_verified_by = str(
current_contact.get(ID_VERIFIED_BY_FIELD, "") or ""
).strip()
existing_verified_at = str(
current_contact.get(ID_VERIFIED_AT_FIELD, "") or ""
).strip()
normalized_verified_by = verified_by.strip()
normalized_verified_at = verified_at.strip()

verified_by_conflict = (
bool(existing_verified_by)
and existing_verified_by != normalized_verified_by
)
verified_at_conflict = (
bool(existing_verified_at)
and existing_verified_at != normalized_verified_at
)
needs_confirmation = verified_by_conflict or verified_at_conflict

if needs_confirmation and not allow_overwrite:
self._audit_command(
interaction=interaction,
action="crm.mark_id_verified",
result="denied",
metadata={
"contact_id": str(contact_id),
"contact_name": contact_name,
"verified_by": verified_by,
"verified_at": verified_at,
"existing_verified_by": existing_verified_by,
"existing_verified_at": existing_verified_at,
"reason": "overwrite_confirmation_needed",
},
resource_type="crm_contact",
resource_id=str(contact_id),
)
confirm_view = MarkIdVerifiedOverwriteConfirmationView(
crm_cog=self,
interaction=interaction,
contact=current_contact,
verified_by=normalized_verified_by,
verified_at=normalized_verified_at,
id_type=id_type,
)
await interaction.followup.send(
(
"⚠️ This contact is already ID verified.\n"
f"- Current verifier: {existing_verified_by}\n"
f"- Current date: {existing_verified_at}\n"
f"- New verifier: {normalized_verified_by}\n"
f"- New date: {normalized_verified_at}\n\n"
"Select **Overwrite** only if you want to replace existing values."
),
view=confirm_view,
)
return False

payload = {
ID_VERIFIED_AT_FIELD: verified_at,
ID_VERIFIED_BY_FIELD: verified_by,
Expand Down
69 changes: 60 additions & 9 deletions tests/unit/test_crm_mark_id_verified.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@

from datetime import date

from unittest.mock import AsyncMock, Mock, patch
from unittest.mock import AsyncMock, Mock, call, patch

import pytest

from five08.discord_bot.cogs.crm import (
CRMCog,
ID_VERIFIED_AT_FIELD,
ID_VERIFIED_BY_FIELD,
MarkIdVerifiedOverwriteConfirmationView,
MarkIdVerifiedSelectionView,
)

Expand Down Expand Up @@ -135,7 +136,10 @@ async def test_mark_id_verified_single_contact_updates_id_fields(
crm_cog._search_contacts_for_mark_id_verification = AsyncMock(
return_value=[contact]
)
crm_cog.espo_api.request.return_value = {"id": "contact-123"}
crm_cog.espo_api.request.side_effect = [
{ID_VERIFIED_BY_FIELD: "", ID_VERIFIED_AT_FIELD: ""},
{"id": "contact-123"},
]

await crm_cog.mark_id_verified.callback(
crm_cog,
Expand All @@ -145,18 +149,65 @@ async def test_mark_id_verified_single_contact_updates_id_fields(
"2026-02-26",
)

crm_cog.espo_api.request.assert_called_once_with(
"PUT",
"Contact/contact-123",
{
ID_VERIFIED_AT_FIELD: "2026-02-26",
ID_VERIFIED_BY_FIELD: "caleb",
},
crm_cog.espo_api.request.assert_has_calls(
[
call("GET", "Contact/contact-123"),
call(
"PUT",
"Contact/contact-123",
{
ID_VERIFIED_AT_FIELD: "2026-02-26",
ID_VERIFIED_BY_FIELD: "caleb",
},
),
]
)
args, kwargs = mock_interaction.followup.send.call_args
assert "embed" in kwargs
assert "ID Verified" in kwargs["embed"].title

@pytest.mark.asyncio
@pytest.mark.parametrize(
"current_values",
[
{ID_VERIFIED_BY_FIELD: "existing-user", ID_VERIFIED_AT_FIELD: "2026-01-01"},
{ID_VERIFIED_BY_FIELD: "caleb", ID_VERIFIED_AT_FIELD: "2025-01-01"},
{ID_VERIFIED_BY_FIELD: "", ID_VERIFIED_AT_FIELD: "2026-01-01"},
{ID_VERIFIED_BY_FIELD: "existing-user", ID_VERIFIED_AT_FIELD: ""},
],
)
async def test_mark_id_verified_single_contact_prompts_for_overwrite_if_already_verified(
self,
crm_cog,
mock_interaction,
current_values: dict[str, str],
):
contact = {
"id": "contact-123",
"name": "Caleb",
"c508Email": "caleb@508.dev",
}
crm_cog._search_contacts_for_mark_id_verification = AsyncMock(
return_value=[contact]
)
crm_cog.espo_api.request.return_value = {
**current_values,
"id": "contact-123",
}

await crm_cog.mark_id_verified.callback(
crm_cog,
mock_interaction,
"caleb",
"caleb",
"2026-02-26",
)

crm_cog.espo_api.request.assert_called_once_with("GET", "Contact/contact-123")
args, kwargs = mock_interaction.followup.send.call_args
assert "already ID verified" in args[0]
assert kwargs["view"].__class__ is MarkIdVerifiedOverwriteConfirmationView

@pytest.mark.asyncio
async def test_mark_id_verified_multiple_contacts_shows_selector(
self,
Expand Down