refactor(tenancy): simplify team table + move actions to edit modal
Some checks failed
CI / ruff (push) Successful in 15s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled

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) <noreply@anthropic.com>
This commit is contained in:
2026-03-30 21:08:36 +02:00
parent 0c6d8409c7
commit d685341b04
4 changed files with 201 additions and 224 deletions

View File

@@ -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

View File

@@ -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 <a href="tel:..."> and <a href="mailto:..."> (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 <a href> 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)[^>]*>.*?</\1>", "", 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()