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
228 changes: 224 additions & 4 deletions apps/discord_bot/src/five08/discord_bot/cogs/crm.py
Original file line number Diff line number Diff line change
Expand Up @@ -895,6 +895,115 @@ async def on_submit(self, interaction: discord.Interaction) -> None:
)


class ResumeEditLocationModal(discord.ui.Modal, title="Edit Location"):
"""Modal for editing proposed location fields before confirmation."""

city_input: discord.ui.TextInput = discord.ui.TextInput(
label="City",
required=False,
max_length=100,
)
state_input: discord.ui.TextInput = discord.ui.TextInput(
label="State / Region",
required=False,
max_length=100,
)
country_input: discord.ui.TextInput = discord.ui.TextInput(
label="Country",
required=False,
max_length=100,
)
timezone_input: discord.ui.TextInput = discord.ui.TextInput(
label="Timezone",
required=False,
max_length=100,
)
Comment on lines +901 to +920
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The location modal allows up to 100 characters per field, but normalize_city/normalize_state currently reject values over 40 chars (via _is_plausible_location_phrase(..., max_length=40)). This mismatch will cause the UI to accept input that the backend will always mark invalid. Consider aligning max_length on the TextInputs with the normalization constraints (or loosening the normalization max_length if 100 is intentional).

Copilot uses AI. Check for mistakes.

def __init__(self, *, confirmation_view: "ResumeUpdateConfirmationView") -> None:
super().__init__()
self.confirmation_view = confirmation_view
self.city_input.default = str(
confirmation_view.proposed_updates.get("addressCity") or ""
).strip()
self.state_input.default = str(
confirmation_view.proposed_updates.get("addressState") or ""
).strip()
self.country_input.default = str(
confirmation_view.proposed_updates.get("addressCountry") or ""
).strip()
self.timezone_input.default = str(
confirmation_view.proposed_updates.get("cTimezone") or ""
).strip()

async def on_submit(self, interaction: discord.Interaction) -> None:
from five08.crm_normalization import (
normalize_city,
normalize_country,
normalize_state,
normalize_timezone,
)

raw_city = (self.city_input.value or "").strip()
raw_state = (self.state_input.value or "").strip()
raw_country = (self.country_input.value or "").strip()
raw_timezone = (self.timezone_input.value or "").strip()

Comment on lines +946 to +950
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ResumeEditLocationModal.on_submit treats user-entered strings like "None"/"null" as real values (e.g., normalize_city("None") returns "None"), so submitting the modal can write the literal string "None" into addressCity/addressState rather than clearing the field. Consider pre-normalizing inputs with the same logic as _location_component (treat case-insensitive "none"/"null" as empty) before calling normalize_city/normalize_state, or make the normalization helpers reject these sentinel values.

Suggested change
raw_city = (self.city_input.value or "").strip()
raw_state = (self.state_input.value or "").strip()
raw_country = (self.country_input.value or "").strip()
raw_timezone = (self.timezone_input.value or "").strip()
def _clean_location_input(value: str | None) -> str:
"""Normalize raw user location input before CRM normalization.
Treats case-insensitive 'none'/'null' as empty so those values clear
existing fields instead of being stored literally.
"""
cleaned = (value or "").strip()
if cleaned.lower() in {"none", "null"}:
return ""
return cleaned
raw_city = _clean_location_input(self.city_input.value)
raw_state = _clean_location_input(self.state_input.value)
raw_country = _clean_location_input(self.country_input.value)
raw_timezone = _clean_location_input(self.timezone_input.value)

Copilot uses AI. Check for mistakes.
city = normalize_city(raw_city) if raw_city else None
state = normalize_state(raw_state) if raw_state else None
country = normalize_country(raw_country) if raw_country else None
timezone = normalize_timezone(raw_timezone) if raw_timezone else None
if raw_timezone and not timezone:
timezone = self.confirmation_view.crm_cog._LOCATION_TIMEZONE_ABBREV_MAP.get(
raw_timezone.upper()
)

invalid_fields: list[str] = []
if raw_city and not city:
invalid_fields.append("City")
if raw_state and not state:
invalid_fields.append("State / Region")
if raw_country and not country:
invalid_fields.append("Country")
if raw_timezone and not timezone:
invalid_fields.append("Timezone")

if invalid_fields:
invalid_list = ", ".join(f"`{field}`" for field in invalid_fields)
await interaction.response.send_message(
f"❌ Invalid location fields: {invalid_list}.",
ephemeral=True,
)
return

updates = self.confirmation_view.proposed_updates
if city:
updates["addressCity"] = city
else:
updates.pop("addressCity", None)
if state:
updates["addressState"] = state
else:
updates.pop("addressState", None)
if country:
updates["addressCountry"] = country
else:
updates.pop("addressCountry", None)
if timezone:
updates["cTimezone"] = timezone
else:
updates.pop("cTimezone", None)

summary = self.confirmation_view._format_location_summary(updates)
if summary == "None":
message = "✅ Location updates cleared. Click **Confirm Updates** to apply."
else:
message = (
f"✅ Location updated to `{summary}`. "
"Click **Confirm Updates** to apply."
)
await interaction.response.send_message(message, ephemeral=True)


class ResumeEditWebsitesButton(discord.ui.Button["ResumeUpdateConfirmationView"]):
"""Button that opens the Edit Websites modal."""

Expand Down Expand Up @@ -961,6 +1070,28 @@ async def callback(self, interaction: discord.Interaction) -> None:
)


class ResumeEditLocationButton(discord.ui.Button["ResumeUpdateConfirmationView"]):
"""Button that opens the Edit Location modal."""

def __init__(self) -> None:
super().__init__(
label="Edit Location",
style=discord.ButtonStyle.secondary,
custom_id="resume_edit_location",
)

async def callback(self, interaction: discord.Interaction) -> None:
view = self.view
if not isinstance(view, ResumeUpdateConfirmationView):
await interaction.response.send_message(
"❌ Unable to edit location.", ephemeral=True
)
return
await interaction.response.send_modal(
ResumeEditLocationModal(confirmation_view=view)
)


class ResumeUpdateConfirmationView(discord.ui.View):
"""Confirm extracted profile updates before writing to CRM."""

Expand All @@ -977,9 +1108,17 @@ class ResumeUpdateConfirmationView(discord.ui.View):
"cWebsiteLink": "Website",
"cSocialLinks": "Social Links",
"cSeniority": "Seniority",
"addressCity": "City",
"addressState": "State",
"addressCountry": "Country",
"cTimezone": "Timezone",
"location": "Location",
"cDiscordUserID": "Discord User ID",
"cDiscordUsername": "Discord Username",
}
_LOCATION_FIELDS = frozenset(
{"addressCity", "addressState", "addressCountry", "cTimezone"}
)

def __init__(
self,
Expand Down Expand Up @@ -1015,12 +1154,46 @@ def __init__(
self.add_item(ResumeEditSocialLinksButton())
if proposed_updates.get("skills") or proposed_updates.get("cSkillAttrs"):
self.add_item(ResumeEditSkillsButton())
self.add_item(ResumeEditLocationButton())

def _set_seniority_override(self, value: str) -> str:
self.seniority_override = value
self.proposed_updates["cSeniority"] = value
return _format_seniority_label(value)

@classmethod
def _is_location_field(cls, field: str) -> bool:
return field in cls._LOCATION_FIELDS

@classmethod
def _has_location_updates(cls, values: dict[str, Any]) -> bool:
return any(values.get(field) for field in cls._LOCATION_FIELDS)

Comment on lines +1168 to +1171
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ResumeUpdateConfirmationView._has_location_updates is introduced but appears unused in the module. If it’s not needed, consider removing it to keep the view API minimal; otherwise, wire it into the call sites that need it (e.g., deciding whether to render grouped location output).

Suggested change
@classmethod
def _has_location_updates(cls, values: dict[str, Any]) -> bool:
return any(values.get(field) for field in cls._LOCATION_FIELDS)

Copilot uses AI. Check for mistakes.
@staticmethod
def _location_component(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
if not text or text.casefold() in {"none", "null"}:
return None
return text

@classmethod
def _format_location_summary(cls, values: dict[str, Any]) -> str:
city = cls._location_component(values.get("addressCity"))
state = cls._location_component(values.get("addressState"))
country = cls._location_component(values.get("addressCountry"))
timezone = cls._location_component(values.get("cTimezone"))
location_parts = [part for part in (city, state, country) if part]
location_value = ", ".join(location_parts)
if timezone:
return (
f"{location_value} (Timezone: {timezone})"
if location_value
else f"Timezone: {timezone}"
)
return location_value or "None"

@classmethod
def _field_label(cls, field: str) -> str:
linkedin_field = _configured_linkedin_field_from_settings()
Expand Down Expand Up @@ -1205,8 +1378,17 @@ def _build_applied_updates_lines(
)
lines.append(f"**Skills**: `{truncated_combined_skills}`")

if any(self._is_location_field(field) for field in updated_fields):
location_values = {
field: updated_values.get(field, self.proposed_updates.get(field))
for field in self._LOCATION_FIELDS
}
lines.append(
f"**Location**: `{self._format_location_summary(location_values)}`"
)

for field in self._collapse_updated_fields(updated_fields):
if field == "skills":
if field in {"skills", "location"}:
continue
label = self._field_label(field)
value = updated_values.get(field, self.proposed_updates.get(field))
Expand Down Expand Up @@ -1274,13 +1456,14 @@ def _format_applied_updates_value(cls, applied_lines: list[str]) -> str:
joined = "\n".join(kept)
return cls._truncate_embed_field(joined, cls._APPLIED_FIELD_TOTAL_LIMIT)

@staticmethod
def _collapse_updated_fields(updated_fields: list[str]) -> list[str]:
@classmethod
def _collapse_updated_fields(cls, updated_fields: list[str]) -> list[str]:
"""Collapse skill fields into a single logical skills entry."""
collapsed: list[str] = []
seen: set[str] = set()
has_skills = "skills" in updated_fields
has_skill_attrs = "cSkillAttrs" in updated_fields
has_location = any(field in cls._LOCATION_FIELDS for field in updated_fields)

for field in updated_fields:
normalized_field = field
Expand All @@ -1289,6 +1472,9 @@ def _collapse_updated_fields(updated_fields: list[str]) -> list[str]:
continue
if has_skill_attrs:
normalized_field = "skills"
elif field in cls._LOCATION_FIELDS:
if has_location:
normalized_field = "location"
key = normalized_field.casefold()
if key in seen:
continue
Expand Down Expand Up @@ -2545,7 +2731,39 @@ def format_skill_delta(current: Any, proposed: Any) -> str:

if isinstance(changes, list) and changes:
lines: list[str] = []
for change in changes[:8]:
location_current: dict[str, Any] = {}
location_proposed: dict[str, Any] = {}
non_location_changes: list[dict[str, Any]] = []
for change in changes:
if not isinstance(change, dict):
continue
field_name = str(change.get("field", ""))
if ResumeUpdateConfirmationView._is_location_field(field_name):
location_current[field_name] = change.get("current")
location_proposed[field_name] = change.get("proposed")
continue
non_location_changes.append(change)

if location_current or location_proposed:
current_location = truncate_preview_value(
ResumeUpdateConfirmationView._format_location_summary(
location_current
),
field_name="location",
label="Location",
)
proposed_location = truncate_preview_value(
ResumeUpdateConfirmationView._format_location_summary(
location_proposed
),
field_name="location",
label="Location",
)
lines.append(
f"**Location**: `{current_location}` → `{proposed_location}`"
)

for change in non_location_changes[:8]:
if not isinstance(change, dict):
continue
field_name = str(change.get("field", ""))
Expand Down Expand Up @@ -6969,6 +7187,8 @@ def parse_timezone_candidate(value: str) -> str | None:
else:
city = normalize_city(token0)
country = normalize_country_token(token1)
if not country:
state = normalize_state_token(token1)
elif len(segments) >= 3:
city = normalize_city(", ".join(segments[:-2]))
state_token = segments[-2]
Expand Down
Loading