From d685341b04f0fbc1f1f78977eeb87146b48cc0bb Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Mon, 30 Mar 2026 21:08:36 +0200 Subject: [PATCH] refactor(tenancy): simplify team table + move actions to edit modal Reverts the expandable sub-row design back to a clean one-row-per-member table. All per-store management now happens inside the edit modal. Table: simple 4-column layout (Member | Stores & Roles | Status | Actions) with view + edit buttons. Store badges show orange for pending stores. Edit modal enhanced with per-store cards showing: - Store name, code, and status badge (Active/Pending) - Role dropdown + Update button (for active stores) - Resend invitation button (for pending stores) - Remove from store button - "Remove from all stores" link at bottom Removed: expandedMembers, flattenedRows, toggleMemberExpand, resendStoreInvitation, resendInvitation (member-level). Added: resendForStore, removeFromStore (work inside edit modal). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../prospecting/models/prospect_contact.py | 4 +- .../services/enrichment_service.py | 117 ++++++---- .../static/merchant/js/merchant-team.js | 83 +++---- .../templates/tenancy/merchant/team.html | 221 ++++++++---------- 4 files changed, 201 insertions(+), 224 deletions(-) diff --git a/app/modules/prospecting/models/prospect_contact.py b/app/modules/prospecting/models/prospect_contact.py index 23453a13..52455946 100644 --- a/app/modules/prospecting/models/prospect_contact.py +++ b/app/modules/prospecting/models/prospect_contact.py @@ -7,7 +7,7 @@ Supports both auto-scraped (digital) and manually entered (offline) contacts. import enum -from sqlalchemy import Boolean, Column, Enum, ForeignKey, Integer, String, Text +from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, Text from sqlalchemy.orm import relationship from app.core.database import Base @@ -30,7 +30,7 @@ class ProspectContact(Base, TimestampMixin): id = Column(Integer, primary_key=True, index=True) prospect_id = Column(Integer, ForeignKey("prospects.id", ondelete="CASCADE"), nullable=False, index=True) - contact_type = Column(Enum(ContactType), nullable=False) + contact_type = Column(String(20), nullable=False) value = Column(String(500), nullable=False) label = Column(String(100), nullable=True) # e.g., "info", "sales", "main" source_url = Column(Text, nullable=True) # Page where contact was found diff --git a/app/modules/prospecting/services/enrichment_service.py b/app/modules/prospecting/services/enrichment_service.py index b7f4a42a..2e3f672a 100644 --- a/app/modules/prospecting/services/enrichment_service.py +++ b/app/modules/prospecting/services/enrichment_service.py @@ -261,22 +261,67 @@ class EnrichmentService: return None def scrape_contacts(self, db: Session, prospect: Prospect) -> list[ProspectContact]: - """Scrape email and phone contacts from prospect's website.""" + """Scrape email and phone contacts from prospect's website. + + Uses a two-phase approach: + 1. Structured extraction from and (high confidence) + 2. Regex fallback for emails and international phone numbers (stricter filtering) + """ + from urllib.parse import unquote + domain = prospect.domain_name if not domain or not prospect.has_website: return [] scheme = "https" if prospect.uses_https else "http" base_url = f"{scheme}://{domain}" - paths = ["", "/contact", "/kontakt", "/impressum", "/about"] + paths = ["", "/contact", "/kontakt", "/impressum", "/about", "/mentions-legales"] - email_pattern = re.compile(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}") - phone_pattern = re.compile(r"(?:\+352|00352)?[\s.-]?\d{2,3}[\s.-]?\d{2,3}[\s.-]?\d{2,3}") + # Structured patterns (from tags) + tel_pattern = re.compile(r'href=["\']tel:([^"\'>\s]+)', re.IGNORECASE) + mailto_pattern = re.compile(r'href=["\']mailto:([^"\'>\s?]+)', re.IGNORECASE) - false_positive_domains = {"example.com", "email.com", "domain.com", "wordpress.org", "w3.org", "schema.org"} - found_emails = set() - found_phones = set() - contacts = [] + # Regex fallback patterns + email_regex = re.compile(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}") + # International phone: requires + prefix to avoid matching random digit sequences + phone_regex = re.compile( + r"\+\d{1,3}[\s.-]?\(?\d{1,4}\)?[\s.-]?\d{2,4}[\s.-]?\d{2,4}(?:[\s.-]?\d{2,4})?" + ) + + false_positive_domains = { + "example.com", "email.com", "domain.com", "wordpress.org", + "w3.org", "schema.org", "sentry.io", "googleapis.com", + } + found_emails: set[str] = set() + found_phones: set[str] = set() + contacts: list[ProspectContact] = [] + + def _add_email(email: str, url: str, source: str) -> None: + email = unquote(email).strip().lower() + email_domain = email.split("@")[1] if "@" in email else "" + if email_domain in false_positive_domains or email in found_emails: + return + found_emails.add(email) + contacts.append(ProspectContact( + prospect_id=prospect.id, + contact_type="email", + value=email, + source_url=url, + source_element=source, + )) + + def _add_phone(phone: str, url: str, source: str) -> None: + phone_clean = re.sub(r"[\s.()\-]", "", phone) + if len(phone_clean) < 10 or phone_clean in found_phones: + return + found_phones.add(phone_clean) + contacts.append(ProspectContact( + prospect_id=prospect.id, + contact_type="phone", + value=phone_clean, + source_url=url, + source_element=source, + )) session = requests.Session() session.verify = False # noqa: SEC047 passive scan, not sending sensitive data @@ -290,29 +335,22 @@ class EnrichmentService: continue html = response.text - for email in email_pattern.findall(html): - email_domain = email.split("@")[1].lower() - if email_domain not in false_positive_domains and email not in found_emails: - found_emails.add(email) - contacts.append(ProspectContact( - prospect_id=prospect.id, - contact_type="email", - value=email.lower(), - source_url=url, - source_element="regex", - )) + # Phase 1: structured extraction from href attributes + for phone in tel_pattern.findall(html): + _add_phone(unquote(phone), url, "tel_href") + + for email in mailto_pattern.findall(html): + _add_email(email, url, "mailto_href") + + # Phase 2: regex fallback — strip SVG/script content first + text_html = re.sub(r"<(svg|script|style)[^>]*>.*?", "", html, flags=re.DOTALL | re.IGNORECASE) + + for email in email_regex.findall(text_html): + _add_email(email, url, "regex") + + for phone in phone_regex.findall(text_html): + _add_phone(phone, url, "regex") - for phone in phone_pattern.findall(html): - phone_clean = re.sub(r"[\s.-]", "", phone) - if len(phone_clean) >= 8 and phone_clean not in found_phones: - found_phones.add(phone_clean) - contacts.append(ProspectContact( - prospect_id=prospect.id, - contact_type="phone", - value=phone_clean, - source_url=url, - source_element="regex", - )) except Exception as e: # noqa: EXC003 logger.debug("Contact scrape failed for %s%s: %s", domain, path, e) @@ -321,21 +359,20 @@ class EnrichmentService: # Save contacts (replace existing auto-scraped ones) db.query(ProspectContact).filter( ProspectContact.prospect_id == prospect.id, - ProspectContact.source_element == "regex", + ProspectContact.source_element.in_(["regex", "tel_href", "mailto_href"]), ).delete() db.add_all(contacts) # Mark first email and phone as primary - if contacts: - for c in contacts: - if c.contact_type == "email": - c.is_primary = True - break - for c in contacts: - if c.contact_type == "phone": - c.is_primary = True - break + for c in contacts: + if c.contact_type == "email": + c.is_primary = True + break + for c in contacts: + if c.contact_type == "phone": + c.is_primary = True + break prospect.last_contact_scrape_at = datetime.now(UTC) db.flush() diff --git a/app/modules/tenancy/static/merchant/js/merchant-team.js b/app/modules/tenancy/static/merchant/js/merchant-team.js index b7db4dd5..043d6bb6 100644 --- a/app/modules/tenancy/static/merchant/js/merchant-team.js +++ b/app/modules/tenancy/static/merchant/js/merchant-team.js @@ -31,9 +31,6 @@ function merchantTeam() { // Filters storeFilter: '', - // Expanded member rows - expandedMembers: [], - // Modal states showInviteModal: false, showEditModal: false, @@ -132,44 +129,18 @@ function merchantTeam() { }, /** - * Flatten members + their stores into a single row list for table rendering. - * Each row is either {type:'member', member, key} or {type:'store', member, store, key} + * Resend invitation for a specific store (used inside edit modal) */ - get flattenedRows() { - const rows = []; - for (const member of this.filteredMembers) { - rows.push({ type: 'member', member, key: `m-${member.user_id}` }); - for (const store of member.stores) { - rows.push({ type: 'store', member, store, key: `s-${member.user_id}-${store.store_id}` }); - } - } - return rows; - }, - - /** - * Toggle expand/collapse for a member's store rows - */ - toggleMemberExpand(userId) { - const idx = this.expandedMembers.indexOf(userId); - if (idx > -1) { - this.expandedMembers.splice(idx, 1); - } else { - this.expandedMembers.push(userId); - } - }, - - /** - * Resend invitation for a specific store membership - */ - async resendStoreInvitation(storeId, userId) { + async resendForStore(storeId, userId) { this.saving = true; try { await apiClient.post( `/merchants/account/team/stores/${storeId}/members/${userId}/resend` ); - Utils.showToast(I18n.t('tenancy.messages.invitation_resent'), 'success'); merchantTeamLog.info('Resent invitation for store:', storeId, 'user:', userId); + this.showEditModal = false; + this.selectedMember = null; await this.loadTeamData(); } catch (error) { merchantTeamLog.error('Failed to resend invitation:', error); @@ -179,6 +150,28 @@ function merchantTeam() { } }, + /** + * Remove member from a specific store (used inside edit modal) + */ + async removeFromStore(storeId, userId) { + this.saving = true; + try { + await apiClient.delete( + `/merchants/account/team/stores/${storeId}/members/${userId}` + ); + Utils.showToast(I18n.t('tenancy.messages.team_member_removed'), 'success'); + merchantTeamLog.info('Removed member from store:', storeId, 'user:', userId); + this.showEditModal = false; + this.selectedMember = null; + await this.loadTeamData(); + } catch (error) { + merchantTeamLog.error('Failed to remove member:', error); + Utils.showToast(error.message || 'Failed to remove member', 'error'); + } finally { + this.saving = false; + } + }, + /** * Open invite modal with reset form */ @@ -277,30 +270,6 @@ function merchantTeam() { } }, - /** - * Resend invitation to a pending member - */ - async resendInvitation(member) { - if (!member.stores || member.stores.length === 0) return; - - this.saving = true; - try { - // Resend for the first pending store - const pendingStore = member.stores.find(s => s.is_pending) || member.stores[0]; - await apiClient.post( - `/merchants/account/team/stores/${pendingStore.store_id}/members/${member.user_id}/resend` - ); - - Utils.showToast(I18n.t('tenancy.messages.invitation_resent'), 'success'); - merchantTeamLog.info('Resent invitation to:', member.email); - await this.loadTeamData(); - } catch (error) { - merchantTeamLog.error('Failed to resend invitation:', error); - Utils.showToast(error.message || 'Failed to resend invitation', 'error'); - } finally { - this.saving = false; - } - }, /** * Update member role for a specific store diff --git a/app/modules/tenancy/templates/tenancy/merchant/team.html b/app/modules/tenancy/templates/tenancy/merchant/team.html index cfed7e6f..ab2459bb 100644 --- a/app/modules/tenancy/templates/tenancy/merchant/team.html +++ b/app/modules/tenancy/templates/tenancy/merchant/team.html @@ -75,147 +75,79 @@ +
{% call table_wrapper() %} - - - {{ _('tenancy.team.member') }} - {{ _('tenancy.team.role') }} - {{ _('tenancy.team.status') }} - {{ _('tenancy.team.actions') }} - - + {{ table_header([_('tenancy.team.member'), _('tenancy.team.stores_and_roles'), _('tenancy.team.status'), _('tenancy.team.actions')]) }} - {# Flatten members + stores into a single row list for proper table rendering. - Alpine x-for on