v1
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
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,
|
||||
)
|
||||
Reference in New Issue
Block a user