168 lines
5.5 KiB
Python
168 lines
5.5 KiB
Python
import hashlib
|
|
import secrets
|
|
import uuid
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.auth.deps import get_current_user
|
|
from app.config import settings
|
|
from app.database import get_db
|
|
from app.middleware.rate_limit import limiter
|
|
from app.models.contact_lead import ContactLead
|
|
from app.models.member import Member, MagicLinkToken
|
|
from app.models.user import User
|
|
from app.schemas.contact import (
|
|
ContactLeadCreate,
|
|
ContactLeadInviteRequest,
|
|
ContactLeadInviteResponse,
|
|
ContactLeadResponse,
|
|
ContactLeadUpdate,
|
|
)
|
|
from app.services.email import send_onboarding_invite
|
|
|
|
router = APIRouter(tags=["Contact Leads"])
|
|
|
|
|
|
def _split_name(full_name: str) -> tuple[str, str]:
|
|
parts = [part for part in full_name.strip().split() if part]
|
|
if not parts:
|
|
return "Goodwalk", "Client"
|
|
if len(parts) == 1:
|
|
return parts[0], "Client"
|
|
return parts[0], " ".join(parts[1:])
|
|
|
|
|
|
def _normalise_services(payload: ContactLeadCreate) -> str | None:
|
|
if payload.services:
|
|
return ", ".join(payload.services)
|
|
if payload.service:
|
|
return payload.service.strip() or None
|
|
return None
|
|
|
|
|
|
@router.post("/api/contact", response_model=ContactLeadResponse, status_code=201)
|
|
@limiter.limit("10/minute")
|
|
async def submit_contact_lead(
|
|
request: Request,
|
|
response: Response,
|
|
data: ContactLeadCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
lead = ContactLead(
|
|
full_name=data.name.strip(),
|
|
email=data.email.strip().lower(),
|
|
phone=(data.phone or "").strip() or None,
|
|
requested_services=_normalise_services(data),
|
|
pet_name=(data.petName or "").strip() or None,
|
|
pet_breed=(data.petBreed or "").strip() or None,
|
|
suburb=(data.location or "").strip() or None,
|
|
service_area_status=(data.serviceAreaStatus or "").strip() or None,
|
|
message=(data.message or "").strip() or None,
|
|
source=data.source,
|
|
status="invite",
|
|
metadata_json={
|
|
"services": data.services,
|
|
"service": data.service,
|
|
},
|
|
)
|
|
db.add(lead)
|
|
await db.flush()
|
|
await db.refresh(lead)
|
|
return lead
|
|
|
|
|
|
@router.get("/api/v1/admin/leads", response_model=list[ContactLeadResponse])
|
|
async def admin_list_leads(
|
|
_admin: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
result = await db.execute(select(ContactLead).order_by(ContactLead.created_at.desc()))
|
|
return result.scalars().all()
|
|
|
|
|
|
@router.put("/api/v1/admin/leads/{lead_id}", response_model=ContactLeadResponse)
|
|
async def admin_update_lead(
|
|
lead_id: uuid.UUID,
|
|
data: ContactLeadUpdate,
|
|
_admin: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
result = await db.execute(select(ContactLead).where(ContactLead.id == lead_id))
|
|
lead = result.scalars().first()
|
|
if lead is None:
|
|
raise HTTPException(status_code=404, detail="Lead not found.")
|
|
|
|
for field, value in data.model_dump(exclude_unset=True).items():
|
|
setattr(lead, field, value)
|
|
await db.flush()
|
|
await db.refresh(lead)
|
|
return lead
|
|
|
|
|
|
@router.post("/api/v1/admin/leads/{lead_id}/invite", response_model=ContactLeadInviteResponse)
|
|
async def admin_invite_lead(
|
|
lead_id: uuid.UUID,
|
|
data: ContactLeadInviteRequest,
|
|
_admin: User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
result = await db.execute(select(ContactLead).where(ContactLead.id == lead_id))
|
|
lead = result.scalars().first()
|
|
if lead is None:
|
|
raise HTTPException(status_code=404, detail="Lead not found.")
|
|
|
|
existing_member_result = await db.execute(select(Member).where(Member.email == lead.email))
|
|
member = existing_member_result.scalars().first()
|
|
|
|
if member is None:
|
|
first_name, last_name = _split_name(lead.full_name)
|
|
member = Member(
|
|
email=lead.email,
|
|
first_name=first_name,
|
|
last_name=last_name,
|
|
phone=lead.phone,
|
|
address=lead.suburb,
|
|
onboarding_data={
|
|
"dog_name": lead.pet_name,
|
|
"dog_breed": lead.pet_breed,
|
|
"preferred_service": lead.requested_services,
|
|
"lead_message": lead.message,
|
|
"service_area_status": lead.service_area_status,
|
|
"source": lead.source,
|
|
},
|
|
is_claimed=False,
|
|
is_active=True,
|
|
member_status="invited",
|
|
)
|
|
db.add(member)
|
|
await db.flush()
|
|
|
|
lead.invited_member_id = member.id
|
|
lead.invited_at = datetime.now(timezone.utc)
|
|
lead.status = "invited"
|
|
await db.flush()
|
|
await db.refresh(lead)
|
|
await db.refresh(member)
|
|
|
|
if data.send_email:
|
|
plaintext_token = secrets.token_urlsafe(32)
|
|
token_hash = hashlib.sha256(plaintext_token.encode()).hexdigest()
|
|
magic_token = MagicLinkToken(
|
|
member_id=member.id,
|
|
token_hash=token_hash,
|
|
expires_at=datetime.now(timezone.utc) + timedelta(days=7),
|
|
)
|
|
db.add(magic_token)
|
|
await db.flush()
|
|
magic_url = f"{settings.MEMBERS_URL.rstrip('/')}/join?token={plaintext_token}"
|
|
await send_onboarding_invite(lead.email, member.first_name, magic_url)
|
|
|
|
return ContactLeadInviteResponse(
|
|
lead=ContactLeadResponse.model_validate(lead),
|
|
member_id=member.id,
|
|
member_status=member.member_status,
|
|
)
|