Files
Project_Velocity/backend/api/routes_crm_imports.py
2026-04-28 11:32:56 +05:30

1523 lines
57 KiB
Python

"""
backend/api/routes_crm_imports.py
CRM Import Route Family + Client 360 Routes
Implements the canonical CRM API surface as specified in Doc 09 (Root API Spec):
POST /api/crm/imports — upload CSV batch
GET /api/crm/imports — list import batches
GET /api/crm/imports/{id} — get batch detail + proposals
PUT /api/crm/imports/{id}/approve-proposal — approve a single proposal
POST /api/crm/imports/{id}/commit — commit approved proposals to canonical
GET /api/crm/contacts — canonical contact list (with QD summary)
GET /api/crm/contacts/{id} — canonical contact detail
GET /api/crm/client-360/{id} — Client 360 aggregated snapshot
GET /api/crm/opportunities — opportunity pipeline list
PATCH /api/crm/opportunities/{id} — opportunity/deal update
GET /api/crm/tasks — reminder/task list
PATCH /api/crm/tasks/{id} — reminder/task lifecycle update
GET /api/crm/kanban — kanban board (canonical leads)
PATCH /api/crm/leads/{id}/stage — canonical lead stage transition
Uses canonical crm_*, intel_*, workflow_* tables.
"""
from __future__ import annotations
import json
import logging
import uuid
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request, UploadFile, File, status
from pydantic import BaseModel, Field
from backend.auth.dependencies import UserPrincipal, get_current_user
from backend.crm.canonical_schema import ensure_canonical_crm_schema
from backend.services.imports.ingest_service import (
parse_csv_content,
infer_column_mapping,
build_normalized_proposals,
create_import_batch_record,
persist_import_batch,
persist_proposals_as_workflow_actions,
)
from backend.services.client_graph.aggregation_service import (
get_client_360,
get_contact_list,
)
logger = logging.getLogger("velocity.api.crm_imports")
# Canonical CRM surfaces are shared with the authenticated WebOS session model.
# Keep the entire router behind Velocity bearer auth so we do not reintroduce
# an unauthenticated read/write path while the canonical tenant model evolves.
router = APIRouter(dependencies=[Depends(get_current_user)])
CANONICAL_LEAD_STAGES = (
"new",
"contacted",
"qualified",
"site_visit_scheduled",
"site_visited",
"negotiation",
"booking_initiated",
"booked",
"lost",
"dormant",
)
CANONICAL_TASK_STATUSES = ("pending", "confirmed", "done", "snoozed", "cancelled")
CANONICAL_OPPORTUNITY_STAGES = (
"prospect",
"qualified",
"proposal",
"site_visit",
"negotiation",
"booking",
"agreement",
"closed_won",
"closed_lost",
)
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
async def _get_pool(request: Request):
await ensure_canonical_crm_schema(request.app)
pool = getattr(request.app.state, "db_pool", None)
if pool is None:
raise HTTPException(status_code=503, detail="Database unavailable.")
return pool
def _tenant_scope(user: UserPrincipal) -> str:
return user.tenant_id
def _parse_optional_datetime(value: str | None, *, field_name: str) -> datetime | None:
if value is None:
return None
normalized = value.strip()
if not normalized:
return None
try:
parsed = datetime.fromisoformat(normalized.replace("Z", "+00:00"))
except ValueError as exc:
raise HTTPException(status_code=422, detail=f"Invalid ISO-8601 timestamp for '{field_name}'.") from exc
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def _parse_optional_date(value: str | None, *, field_name: str):
if value is None:
return None
normalized = value.strip()
if not normalized:
return None
try:
return datetime.fromisoformat(normalized.replace("Z", "+00:00")).date()
except ValueError as exc:
raise HTTPException(status_code=422, detail=f"Invalid ISO-8601 date for '{field_name}'.") from exc
def _optional_uuid(value: str | None) -> str | None:
if not value:
return None
try:
return str(uuid.UUID(value))
except ValueError:
return None
def _normalize_choice(value: str, *, allowed: tuple[str, ...], field_name: str) -> str:
normalized = value.strip().lower()
if normalized not in allowed:
allowed_values = ", ".join(allowed)
raise HTTPException(status_code=422, detail=f"Unsupported {field_name}. Expected one of: {allowed_values}.")
return normalized
def _fields_set(model: BaseModel) -> set[str]:
fields = getattr(model, "model_fields_set", None)
if fields is not None:
return set(fields)
return set(getattr(model, "__fields_set__", set()))
def _row_value(row, key: str, default: Any = None) -> Any:
try:
return row[key]
except (KeyError, IndexError):
return default
def _opportunity_payload(row) -> dict[str, Any]:
value = _row_value(row, "value")
expected_close_date = _row_value(row, "expected_close_date")
return {
"opportunity_id": str(row["opportunity_id"]),
"stage": row["stage"],
"value": float(value) if value is not None else None,
"probability": _row_value(row, "probability"),
"expected_close_date": expected_close_date.isoformat() if expected_close_date else None,
"next_action": _row_value(row, "next_action"),
"notes": _row_value(row, "notes"),
"person_id": str(row["person_id"]),
"client_name": row["full_name"],
"client_phone": row["primary_phone"],
"project_name": row["project_name"],
}
# ── Models ────────────────────────────────────────────────────────────────────
class ProposalApprovalRequest(BaseModel):
proposal_id: str
decision: str = Field(..., pattern="^(approved|rejected|needs_more_info)$")
notes: str = Field(default="", max_length=2000)
class CreatePersonRequest(BaseModel):
full_name: str = Field(..., min_length=1, max_length=200)
primary_email: str | None = None
primary_phone: str | None = None
buyer_type: str | None = None
budget_band: str | None = None
project_name: str | None = None
source_system: str = "manual"
notes: str | None = None
metadata_json: dict[str, Any] = Field(default_factory=dict)
class CreateLeadRequest(BaseModel):
person_id: str
status: str = "new"
budget_band: str | None = None
urgency: str | None = None
financing_posture: str | None = None
assigned_user_id: str | None = None
metadata_json: dict[str, Any] = Field(default_factory=dict)
class CreateReminderRequest(BaseModel):
person_id: str
lead_id: str | None = None
reminder_type: str = "follow_up"
title: str = Field(..., min_length=1, max_length=500)
notes: str | None = None
due_at: str | None = None
priority: str = "normal"
<<<<<<< HEAD
class ClientDataPatchRequest(BaseModel):
full_name: str | None = Field(default=None, max_length=256)
primary_email: str | None = Field(default=None, max_length=256)
primary_phone: str | None = Field(default=None, max_length=64)
buyer_type: str | None = Field(default=None, max_length=128)
communication_preference: str | None = Field(default=None, max_length=64)
best_contact_time: str | None = Field(default=None, max_length=128)
lead_status: str | None = Field(default=None, max_length=64)
budget_band: str | None = Field(default=None, max_length=128)
urgency: str | None = Field(default=None, max_length=64)
=======
class UpdateReminderRequest(BaseModel):
status: str = Field(..., min_length=1, max_length=32)
due_at: str | None = None
notes: str | None = Field(default=None, max_length=4000)
class UpdateLeadStageRequest(BaseModel):
status: str = Field(..., min_length=1, max_length=64)
notes: str | None = Field(default=None, max_length=2000)
class UpdateOpportunityRequest(BaseModel):
stage: str | None = Field(default=None, max_length=64)
value: float | None = Field(default=None, ge=0)
probability: int | None = Field(default=None, ge=0, le=100)
expected_close_date: str | None = None
next_action: str | None = Field(default=None, max_length=1000)
notes: str | None = Field(default=None, max_length=4000)
>>>>>>> sayan-feat/#38
# ── Import Endpoints ──────────────────────────────────────────────────────────
@router.post("/crm/imports", status_code=201, tags=["CRM Imports"])
async def upload_crm_import(
request: Request,
file: UploadFile = File(...),
source_system: str = Query(default="csv_upload"),
user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
"""
Upload a CSV file to start a CRM import batch.
Parses headers, infers column mapping, and creates workflow_actions proposals.
"""
pool = await _get_pool(request)
content_bytes = await file.read()
try:
content = content_bytes.decode("utf-8")
except UnicodeDecodeError:
content = content_bytes.decode("latin-1")
parsed = parse_csv_content(content)
mapping = infer_column_mapping(parsed["headers"])
batch = create_import_batch_record(
filename=file.filename or "upload.csv",
row_count=parsed["row_count"],
mapping_manifest=mapping,
source_system=source_system,
tenant_id=_tenant_scope(user),
)
proposals = build_normalized_proposals(
rows=parsed["rows"],
mapping=mapping["mapped"],
batch_id=batch["batch_id"],
source_system=source_system,
)
async with pool.acquire() as conn:
await persist_import_batch(conn, batch)
inserted = await persist_proposals_as_workflow_actions(conn, proposals, _tenant_scope(user))
logger.info("Import batch %s: %d rows, %d proposals", batch["batch_id"], parsed["row_count"], inserted)
return {
"status": "ok",
"data": {
"batch_id": batch["batch_id"],
"row_count": parsed["row_count"],
"mapped_columns": mapping["mapped_count"],
"unmapped_columns": mapping["unmapped_count"],
"mapping_confidence": mapping["confidence"],
"proposals_created": inserted,
"parse_errors": parsed["parse_errors"],
"lifecycle": "parsed",
"message": f"Import batch created. {inserted} proposals queued for review.",
},
}
@router.get("/crm/imports", tags=["CRM Imports"])
async def list_import_batches(
request: Request,
lifecycle: str | None = None,
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
"""List all CRM import batches with lifecycle status."""
pool = await _get_pool(request)
clauses = ["tenant_id = $1"]
params: list[Any] = [_tenant_scope(user)]
if lifecycle:
params.append(lifecycle)
clauses.append(f"lifecycle = ${len(params)}::import_lifecycle")
params.extend([limit, offset])
where = " AND ".join(clauses)
async with pool.acquire() as conn:
rows = await conn.fetch(
f"""
SELECT batch_id, source_system, uploaded_filename, row_count,
mapped_count, unresolved_count, lifecycle, created_at, updated_at
FROM workflow_import_batches
WHERE {where}
ORDER BY created_at DESC
LIMIT ${len(params) - 1} OFFSET ${len(params)}
""",
*params,
)
total = await conn.fetchval(
f"SELECT COUNT(*) FROM workflow_import_batches WHERE {where}",
*params[:-2],
)
batches = [
{
"batch_id": str(r["batch_id"]),
"source_system": r["source_system"],
"filename": r["uploaded_filename"],
"row_count": r["row_count"],
"mapped_count": r["mapped_count"],
"unresolved_count": r["unresolved_count"],
"lifecycle": r["lifecycle"],
"created_at": r["created_at"].isoformat() if r["created_at"] else None,
}
for r in rows
]
return {"status": "ok", "data": batches, "meta": {"total": total, "limit": limit, "offset": offset}}
@router.get("/crm/imports/{batch_id}", tags=["CRM Imports"])
async def get_import_batch(
request: Request,
batch_id: str,
user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
"""Get import batch detail including pending proposals."""
pool = await _get_pool(request)
async with pool.acquire() as conn:
batch_row = await conn.fetchrow(
"SELECT * FROM workflow_import_batches WHERE batch_id = $1::uuid AND tenant_id = $2",
batch_id,
_tenant_scope(user),
)
if not batch_row:
raise HTTPException(status_code=404, detail=f"Import batch '{batch_id}' not found.")
proposal_rows = await conn.fetch(
"""
SELECT action_id, proposal_payload, confidence, status, approval_required, created_at
FROM workflow_actions
WHERE tenant_id = $1
AND action_type = 'import_proposal'
AND proposal_payload->>'batch_id' = $2
ORDER BY (proposal_payload->>'row_number')::int ASC
LIMIT 200
""",
_tenant_scope(user),
batch_id,
)
proposals = [
{
"proposal_id": str(r["action_id"]),
"payload": r["proposal_payload"],
"confidence": float(r["confidence"]) if r["confidence"] else 0.0,
"status": r["status"],
"review_required": r["approval_required"],
}
for r in proposal_rows
]
return {
"status": "ok",
"data": {
"batch_id": str(batch_row["batch_id"]),
"source_system": batch_row["source_system"],
"filename": batch_row["uploaded_filename"],
"row_count": batch_row["row_count"],
"mapping_manifest": batch_row["mapping_manifest"],
"lifecycle": batch_row["lifecycle"],
"proposals": proposals,
"proposal_count": len(proposals),
},
}
@router.put("/crm/imports/{batch_id}/review-proposal", tags=["CRM Imports"])
async def review_proposal(
request: Request,
batch_id: str,
body: ProposalApprovalRequest,
user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
"""
Human review of a single import proposal.
Creates a workflow_approvals record and updates the action status.
Approved actions with high confidence may be auto-staged for commit.
"""
pool = await _get_pool(request)
async with pool.acquire() as conn:
action = await conn.fetchrow(
"""
SELECT action_id, confidence, approval_required
FROM workflow_actions
WHERE action_id = $1::uuid
AND tenant_id = $2
AND proposal_payload->>'batch_id' = $3
""",
body.proposal_id,
_tenant_scope(user),
batch_id,
)
if not action:
raise HTTPException(status_code=404, detail="Proposal not found.")
decision_id = str(uuid.uuid4())
new_status = "approved" if body.decision == "approved" else "rejected"
await conn.execute(
"""
INSERT INTO workflow_approvals (decision_id, tenant_id, action_id, decision, decision_notes, decided_at)
VALUES ($1::uuid, $2, $3::uuid, $4, $5, NOW())
""",
decision_id,
_tenant_scope(user),
body.proposal_id,
body.decision,
body.notes,
)
await conn.execute(
"""
UPDATE workflow_actions
SET status = $1::wf_status, updated_at = NOW()
WHERE action_id = $2::uuid
AND tenant_id = $3
""",
new_status,
body.proposal_id,
_tenant_scope(user),
)
return {
"status": "ok",
"data": {
"decision_id": decision_id,
"proposal_id": body.proposal_id,
"decision": body.decision,
"message": f"Proposal {body.decision}.",
},
}
@router.post("/crm/imports/{batch_id}/commit", tags=["CRM Imports"])
async def commit_approved_proposals(
request: Request,
batch_id: str,
user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
"""
Commit all approved proposals for a batch into canonical crm_people + crm_leads tables.
Only approved proposals are committed. Rejected/pending are skipped.
This implements the writeback flow from Doc 07 and Doc 09.
"""
pool = await _get_pool(request)
committed = 0
skipped = 0
errors: list[str] = []
async with pool.acquire() as conn:
approved_rows = await conn.fetch(
"""
SELECT action_id, proposal_payload
FROM workflow_actions
WHERE tenant_id = $1
AND action_type = 'import_proposal'
AND proposal_payload->>'batch_id' = $2
AND status = 'approved'
""",
_tenant_scope(user),
batch_id,
)
for row in approved_rows:
try:
payload = row["proposal_payload"]
canonical = payload.get("canonical_payload", {})
if not canonical.get("full_name"):
skipped += 1
continue
person_id = str(uuid.uuid4())
await conn.execute(
"""
INSERT INTO crm_people (
person_id, tenant_id, full_name, primary_email, primary_phone,
buyer_type, source_confidence, metadata_json, created_at, updated_at
) VALUES (
$1::uuid, $2, $3, $4, $5, $6, $7, $8::jsonb, NOW(), NOW()
)
ON CONFLICT DO NOTHING
""",
person_id,
_tenant_scope(user),
canonical.get("full_name"),
canonical.get("primary_email"),
canonical.get("primary_phone"),
canonical.get("buyer_type"),
payload.get("confidence", 0.5),
json.dumps({"source_batch": batch_id, "import_row": payload.get("row_number")}),
)
if canonical.get("status") or canonical.get("budget_band"):
lead_id = str(uuid.uuid4())
await conn.execute(
"""
INSERT INTO crm_leads (
lead_id, tenant_id, person_id, source_system, status, budget_band,
metadata_json, created_at, updated_at
) VALUES (
$1::uuid, $2, $3::uuid, $4, 'new'::crm_lead_status, $5, $6::jsonb, NOW(), NOW()
)
ON CONFLICT DO NOTHING
""",
lead_id,
_tenant_scope(user),
person_id,
payload.get("source_system", "csv_upload"),
canonical.get("budget_band"),
json.dumps({"import_batch": batch_id}),
)
# Stage property interest if project_name present
if canonical.get("project_name"):
await conn.execute(
"""
INSERT INTO crm_property_interests (
interest_id, tenant_id, person_id, lead_id, project_name, created_at
) VALUES (
$1::uuid, $2, $3::uuid, $4::uuid, $5, NOW()
)
ON CONFLICT DO NOTHING
""",
str(uuid.uuid4()),
_tenant_scope(user),
person_id,
lead_id,
canonical.get("project_name"),
)
# Mark action as executed
await conn.execute(
"""
UPDATE workflow_actions
SET status = 'executed'::wf_status, updated_at = NOW()
WHERE action_id = $1::uuid
AND tenant_id = $2
""",
row["action_id"],
_tenant_scope(user),
)
committed += 1
except Exception as e:
errors.append(f"Proposal {row['action_id']}: {str(e)}")
skipped += 1
# Update batch lifecycle
await conn.execute(
"""
UPDATE workflow_import_batches
SET lifecycle = 'committed'::import_lifecycle, canonical_count = $1, updated_at = NOW()
WHERE batch_id = $2
AND tenant_id = $3
""",
committed,
batch_id,
_tenant_scope(user),
)
return {
"status": "ok",
"data": {
"batch_id": batch_id,
"committed": committed,
"skipped": skipped,
"errors": errors,
"lifecycle": "committed",
},
}
# ── Contact / Person Endpoints ──────────────────────────────────────────────
@router.get("/crm/contacts", tags=["CRM Contacts"])
async def list_contacts(
request: Request,
search: str | None = Query(default=None),
buyer_type: str | None = Query(default=None),
status: str | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
"""Canonical contact list with QD summary, interaction count, and lead status."""
pool = await _get_pool(request)
async with pool.acquire() as conn:
result = await get_contact_list(conn, _tenant_scope(user), search, buyer_type, status, limit, offset)
return {"status": "ok", "data": result}
@router.post("/crm/contacts", status_code=201, tags=["CRM Contacts"])
async def create_contact(
request: Request,
body: CreatePersonRequest,
user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
"""Create a new canonical person record manually."""
pool = await _get_pool(request)
person_id = str(uuid.uuid4())
async with pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO crm_people (
person_id, tenant_id, full_name, primary_email, primary_phone,
buyer_type, source_confidence, metadata_json, created_at, updated_at
) VALUES ($1::uuid, $2, $3, $4, $5, $6, 1.0, $7::jsonb, NOW(), NOW())
""",
person_id,
_tenant_scope(user),
body.full_name,
body.primary_email,
body.primary_phone,
body.buyer_type,
json.dumps({**body.metadata_json, "manual_entry": True}),
)
if body.project_name or body.budget_band:
lead_id = str(uuid.uuid4())
await conn.execute(
"""
INSERT INTO crm_leads (
lead_id, tenant_id, person_id, source_system, status, budget_band,
metadata_json, created_at, updated_at
) VALUES ($1::uuid, $2, $3::uuid, $4, 'new'::crm_lead_status, $5, '{}'::jsonb, NOW(), NOW())
""",
lead_id,
_tenant_scope(user),
person_id,
body.source_system,
body.budget_band,
)
if body.project_name:
await conn.execute(
"""
INSERT INTO crm_property_interests (interest_id, tenant_id, person_id, lead_id, project_name, created_at)
VALUES ($1::uuid, $2, $3::uuid, $4::uuid, $5, NOW())
""",
str(uuid.uuid4()),
_tenant_scope(user),
person_id,
lead_id,
body.project_name,
)
return {"status": "ok", "data": {"person_id": person_id, "full_name": body.full_name}}
@router.get("/crm/contacts/{person_id}", tags=["CRM Contacts"])
async def get_contact(
request: Request,
person_id: str,
user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
"""Get a single canonical contact record."""
pool = await _get_pool(request)
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT person_id, full_name, primary_email, primary_phone, secondary_phone,
buyer_type, persona_labels, source_confidence, created_at, updated_at
FROM crm_people
WHERE person_id = $1::uuid
AND tenant_id = $2
""",
person_id,
_tenant_scope(user),
)
if not row:
raise HTTPException(status_code=404, detail=f"Contact '{person_id}' not found.")
return {
"status": "ok",
"data": {
"person_id": str(row["person_id"]),
"full_name": row["full_name"],
"primary_email": row["primary_email"],
"primary_phone": row["primary_phone"],
"buyer_type": row["buyer_type"],
"persona_labels": row["persona_labels"] or [],
"source_confidence": float(row["source_confidence"] or 0.0),
},
}
# ── Client 360 Endpoint ────────────────────────────────────────────────────
@router.get("/crm/client-360/{person_id}", tags=["CRM Client 360"])
async def client_360(
request: Request,
person_id: str,
user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
"""
Aggregated Client360 dossier — identity, opportunities, interactions,
property interests, tasks, QD overview, risk flags, and next actions.
Derived read model — not primary truth.
"""
pool = await _get_pool(request)
async with pool.acquire() as conn:
snapshot = await get_client_360(conn, _tenant_scope(user), person_id)
if not snapshot:
raise HTTPException(status_code=404, detail=f"Client '{person_id}' not found.")
return {"status": "ok", "data": snapshot}
# ── Opportunities Endpoint ─────────────────────────────────────────────────
@router.get("/crm/opportunities", tags=["CRM Opportunities"])
async def list_opportunities(
request: Request,
stage: str | None = None,
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
"""Canonical opportunity pipeline list."""
pool = await _get_pool(request)
clauses = ["co.tenant_id = $1", "cl.tenant_id = $1", "p.tenant_id = $1"]
params: list[Any] = [_tenant_scope(user)]
if stage:
params.append(stage)
clauses.append(f"co.stage = ${len(params)}::crm_opportunity_stage")
params.extend([limit, offset])
where = " AND ".join(clauses)
async with pool.acquire() as conn:
rows = await conn.fetch(
f"""
SELECT co.opportunity_id, co.stage, co.value, co.probability,
co.expected_close_date, co.next_action,
p.person_id, p.full_name, p.primary_phone,
ip.project_name,
co.created_at, co.updated_at
FROM crm_opportunities co
INNER JOIN crm_leads cl ON cl.lead_id = co.lead_id
INNER JOIN crm_people p ON p.person_id = cl.person_id
LEFT JOIN inventory_projects ip ON ip.project_id = co.project_id
WHERE {where}
ORDER BY co.updated_at DESC
LIMIT ${len(params) - 1} OFFSET ${len(params)}
""",
*params,
)
opportunities = [
_opportunity_payload(r)
for r in rows
]
return {"status": "ok", "data": opportunities, "meta": {"count": len(opportunities)}}
@router.patch("/crm/opportunities/{opportunity_id}", tags=["CRM Opportunities"])
async def update_opportunity(
request: Request,
opportunity_id: str,
body: UpdateOpportunityRequest,
user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
"""Update canonical opportunity/deal fields without leaving tenant scope."""
pool = await _get_pool(request)
updated_fields = _fields_set(body)
if not updated_fields:
raise HTTPException(status_code=422, detail="At least one opportunity field must be provided.")
next_stage = None
if "stage" in updated_fields:
if body.stage is None:
raise HTTPException(status_code=422, detail="Opportunity stage cannot be null.")
next_stage = _normalize_choice(
body.stage,
allowed=CANONICAL_OPPORTUNITY_STAGES,
field_name="opportunity stage",
)
expected_close_date = None
if "expected_close_date" in updated_fields:
expected_close_date = _parse_optional_date(body.expected_close_date, field_name="expected_close_date")
async with pool.acquire() as conn:
existing = await conn.fetchrow(
"""
SELECT co.opportunity_id, co.stage, co.value, co.probability,
co.expected_close_date, co.next_action, co.notes,
p.person_id, p.full_name, p.primary_phone,
ip.project_name
FROM crm_opportunities co
INNER JOIN crm_leads cl
ON cl.lead_id = co.lead_id
AND cl.tenant_id = co.tenant_id
INNER JOIN crm_people p
ON p.person_id = cl.person_id
AND p.tenant_id = co.tenant_id
LEFT JOIN inventory_projects ip ON ip.project_id = co.project_id
WHERE co.opportunity_id = $1::uuid
AND co.tenant_id = $2
""",
opportunity_id,
_tenant_scope(user),
)
if not existing:
raise HTTPException(status_code=404, detail=f"Opportunity '{opportunity_id}' not found.")
row = await conn.fetchrow(
"""
WITH updated AS (
UPDATE crm_opportunities co
SET stage = COALESCE($3::crm_opportunity_stage, co.stage),
value = CASE WHEN $4::boolean THEN $5::numeric ELSE co.value END,
probability = CASE WHEN $6::boolean THEN $7::int ELSE co.probability END,
expected_close_date = CASE WHEN $8::boolean THEN $9::date ELSE co.expected_close_date END,
next_action = CASE WHEN $10::boolean THEN $11 ELSE co.next_action END,
notes = CASE WHEN $12::boolean THEN $13 ELSE co.notes END,
updated_at = NOW()
FROM crm_leads cl
WHERE co.opportunity_id = $1::uuid
AND co.tenant_id = $2
AND cl.lead_id = co.lead_id
AND cl.tenant_id = co.tenant_id
RETURNING co.opportunity_id, co.lead_id, co.stage, co.value, co.probability,
co.expected_close_date, co.next_action, co.notes, co.project_id, co.updated_at
)
SELECT updated.opportunity_id, updated.stage, updated.value, updated.probability,
updated.expected_close_date, updated.next_action, updated.notes,
p.person_id, p.full_name, p.primary_phone,
ip.project_name
FROM updated
INNER JOIN crm_leads cl
ON cl.lead_id = updated.lead_id
AND cl.tenant_id = $2
INNER JOIN crm_people p
ON p.person_id = cl.person_id
AND p.tenant_id = $2
LEFT JOIN inventory_projects ip ON ip.project_id = updated.project_id
""",
opportunity_id,
_tenant_scope(user),
next_stage,
"value" in updated_fields,
body.value,
"probability" in updated_fields,
body.probability,
"expected_close_date" in updated_fields,
expected_close_date,
"next_action" in updated_fields,
body.next_action,
"notes" in updated_fields,
body.notes,
)
return {
"status": "ok",
"data": _opportunity_payload(row),
"meta": {
"previous_stage": existing["stage"],
"changed": _opportunity_payload(existing) != _opportunity_payload(row),
},
}
# ── Tasks / Reminders Endpoint ─────────────────────────────────────────────
@router.get("/crm/tasks", tags=["CRM Tasks"])
async def list_tasks(
request: Request,
status_filter: str | None = Query(default="pending", alias="status"),
assigned_to: str | None = None,
limit: int = Query(default=50, ge=1, le=200),
user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
"""Reminder / task inbox for the CRM operator."""
pool = await _get_pool(request)
clauses = ["ir.tenant_id = $1"]
params: list[Any] = [_tenant_scope(user)]
if status_filter and status_filter.lower() != "all":
params.append(status_filter)
clauses.append(f"ir.status = ${len(params)}")
if assigned_to:
params.append(assigned_to)
clauses.append(f"ir.assigned_to = ${len(params)}::uuid")
params.append(limit)
where = " AND ".join(clauses)
async with pool.acquire() as conn:
rows = await conn.fetch(
f"""
SELECT ir.reminder_id, ir.reminder_type, ir.title, ir.notes,
ir.due_at, ir.status, ir.priority,
p.person_id, p.full_name, p.primary_phone
FROM intel_reminders ir
INNER JOIN crm_people p
ON p.person_id = ir.person_id
AND p.tenant_id = ir.tenant_id
WHERE {where}
ORDER BY ir.due_at ASC NULLS LAST, ir.created_at DESC
LIMIT ${len(params)}
""",
*params,
)
tasks = [
{
"reminder_id": str(r["reminder_id"]),
"reminder_type": r["reminder_type"],
"title": r["title"],
"notes": r["notes"],
"due_at": r["due_at"].isoformat() if r["due_at"] else None,
"status": r["status"],
"priority": r["priority"],
"person_id": str(r["person_id"]),
"client_name": r["full_name"],
"client_phone": r["primary_phone"],
}
for r in rows
]
return {"status": "ok", "data": tasks, "meta": {"count": len(tasks)}}
@router.post("/crm/tasks", status_code=201, tags=["CRM Tasks"])
async def create_task(
request: Request,
body: CreateReminderRequest,
user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
"""Create a reminder / follow-up task."""
pool = await _get_pool(request)
reminder_id = str(uuid.uuid4())
async with pool.acquire() as conn:
due_dt = _parse_optional_datetime(body.due_at, field_name="due_at")
person = await conn.fetchrow(
"""
SELECT person_id
FROM crm_people
WHERE person_id = $1::uuid
AND tenant_id = $2
""",
body.person_id,
_tenant_scope(user),
)
if not person:
raise HTTPException(status_code=404, detail=f"Contact '{body.person_id}' not found.")
if body.lead_id:
lead = await conn.fetchrow(
"""
SELECT lead_id
FROM crm_leads
WHERE lead_id = $1::uuid
AND person_id = $2::uuid
AND tenant_id = $3
""",
body.lead_id,
body.person_id,
_tenant_scope(user),
)
if not lead:
raise HTTPException(status_code=404, detail=f"Lead '{body.lead_id}' not found.")
await conn.execute(
"""
INSERT INTO intel_reminders (
reminder_id, tenant_id, person_id, lead_id, reminder_type, title,
notes, due_at, status, priority, created_by_type, created_at
) VALUES (
$1::uuid, $2, $3::uuid, $4::uuid, $5, $6, $7, $8, 'pending', $9, 'human', NOW()
)
""",
reminder_id,
_tenant_scope(user),
body.person_id,
body.lead_id,
body.reminder_type,
body.title,
body.notes,
due_dt,
body.priority,
)
return {"status": "ok", "data": {"reminder_id": reminder_id, "title": body.title}}
@router.patch("/crm/tasks/{reminder_id}", tags=["CRM Tasks"])
async def update_task(
request: Request,
reminder_id: str,
body: UpdateReminderRequest,
user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
"""Update canonical reminder lifecycle state for an operator-visible task."""
pool = await _get_pool(request)
next_status = _normalize_choice(
body.status,
allowed=CANONICAL_TASK_STATUSES,
field_name="task status",
)
due_dt = _parse_optional_datetime(body.due_at, field_name="due_at")
now = datetime.now(timezone.utc)
if next_status == "snoozed":
if due_dt is None:
raise HTTPException(status_code=422, detail="Snoozed reminders require a future 'due_at' timestamp.")
if due_dt <= now:
raise HTTPException(status_code=422, detail="Snoozed reminders must be assigned a future 'due_at' timestamp.")
async with pool.acquire() as conn:
existing = await conn.fetchrow(
"""
SELECT reminder_id, status, due_at
FROM intel_reminders
WHERE reminder_id = $1::uuid
AND tenant_id = $2
""",
reminder_id,
_tenant_scope(user),
)
if not existing:
raise HTTPException(status_code=404, detail=f"Task '{reminder_id}' not found.")
row = await conn.fetchrow(
"""
UPDATE intel_reminders ir
SET status = $3,
due_at = CASE
WHEN $4::timestamptz IS NULL THEN ir.due_at
ELSE $4::timestamptz
END,
notes = COALESCE($5, ir.notes),
completed_at = CASE
WHEN $3 = 'done' THEN COALESCE(ir.completed_at, NOW())
WHEN $3 IN ('pending', 'confirmed', 'snoozed', 'cancelled') THEN NULL
ELSE ir.completed_at
END
FROM crm_people p
WHERE ir.reminder_id = $1::uuid
AND ir.tenant_id = $2
AND p.person_id = ir.person_id
AND p.tenant_id = ir.tenant_id
RETURNING ir.reminder_id,
ir.reminder_type,
ir.title,
ir.notes,
ir.due_at,
ir.status,
ir.priority,
p.person_id,
p.full_name,
p.primary_phone
""",
reminder_id,
_tenant_scope(user),
next_status,
due_dt,
body.notes,
)
return {
"status": "ok",
"data": {
"reminder_id": str(row["reminder_id"]),
"reminder_type": row["reminder_type"],
"title": row["title"],
"notes": row["notes"],
"due_at": row["due_at"].isoformat() if row["due_at"] else None,
"status": row["status"],
"priority": row["priority"],
"person_id": str(row["person_id"]),
"client_name": row["full_name"],
"client_phone": row["primary_phone"],
},
"meta": {
"previous_status": existing["status"],
"changed": existing["status"] != row["status"],
},
}
# ── Canonical Kanban (from crm_leads) ─────────────────────────────────────
@router.get("/crm/kanban", tags=["CRM Kanban"])
async def get_canonical_kanban(
request: Request,
user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
"""
Canonical Kanban board from crm_leads table.
Groups clients by lead status with QD summary.
"""
pool = await _get_pool(request)
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT
cl.lead_id, cl.status, cl.budget_band, cl.urgency,
p.person_id, p.full_name, p.primary_phone, p.buyer_type,
COALESCE(qs.intent_value, 0.0) AS intent_score
FROM crm_leads cl
INNER JOIN crm_people p
ON p.person_id = cl.person_id
AND p.tenant_id = cl.tenant_id
LEFT JOIN LATERAL (
SELECT MAX(CASE WHEN score_type = 'intent_score' THEN current_value END) AS intent_value
FROM intel_qd_scores
WHERE tenant_id = cl.tenant_id
AND person_id = p.person_id
) qs ON TRUE
WHERE cl.tenant_id = $1
ORDER BY qs.intent_value DESC NULLS LAST, cl.updated_at DESC
""",
_tenant_scope(user),
)
grouped: dict[str, list[dict]] = {s: [] for s in CANONICAL_LEAD_STAGES}
for r in rows:
s = r["status"] or "new"
grouped.setdefault(s, []).append({
"lead_id": str(r["lead_id"]),
"person_id": str(r["person_id"]),
"client_name": r["full_name"],
"client_phone": r["primary_phone"],
"buyer_type": r["buyer_type"],
"budget_band": r["budget_band"],
"urgency": r["urgency"],
"intent_score": float(r["intent_score"]),
})
board = [
{
"status": s,
"label": s.replace("_", " ").title(),
"count": len(grouped.get(s, [])),
"items": grouped.get(s, []),
}
for s in CANONICAL_LEAD_STAGES
]
return {"status": "ok", "data": board}
@router.patch("/crm/leads/{lead_id}/stage", tags=["CRM Kanban"])
async def update_lead_stage(
request: Request,
lead_id: str,
body: UpdateLeadStageRequest,
user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
"""Move a canonical CRM lead between funnel stages and persist audit history."""
pool = await _get_pool(request)
next_status = _normalize_choice(
body.status,
allowed=CANONICAL_LEAD_STAGES,
field_name="lead stage",
)
changed_by = _optional_uuid(user.user_id)
async with pool.acquire() as conn:
existing = await conn.fetchrow(
"""
SELECT cl.lead_id,
cl.person_id,
cl.status,
cl.budget_band,
cl.urgency,
p.full_name,
p.primary_phone
FROM crm_leads cl
INNER JOIN crm_people p
ON p.person_id = cl.person_id
AND p.tenant_id = cl.tenant_id
WHERE cl.lead_id = $1::uuid
AND cl.tenant_id = $2
""",
lead_id,
_tenant_scope(user),
)
if not existing:
raise HTTPException(status_code=404, detail=f"Lead '{lead_id}' not found.")
changed = existing["status"] != next_status
if changed:
updated = await conn.fetchrow(
"""
UPDATE crm_leads
SET status = $3::crm_lead_status,
updated_at = NOW()
WHERE lead_id = $1::uuid
AND tenant_id = $2
RETURNING lead_id, person_id, status, budget_band, urgency
""",
lead_id,
_tenant_scope(user),
next_status,
)
await conn.execute(
"""
INSERT INTO crm_stage_history (
history_id, lead_id, from_status, to_status, changed_by,
changed_by_type, notes, happened_at
) VALUES (
$1::uuid, $2::uuid, $3, $4, $5::uuid, 'human', $6, NOW()
)
""",
str(uuid.uuid4()),
lead_id,
existing["status"],
next_status,
changed_by,
body.notes,
)
else:
updated = existing
return {
"status": "ok",
"data": {
"lead_id": str(updated["lead_id"]),
"person_id": str(updated["person_id"]),
"status": updated["status"],
"budget_band": updated["budget_band"],
"urgency": updated["urgency"],
"client_name": existing["full_name"],
"client_phone": existing["primary_phone"],
},
"meta": {
"previous_status": existing["status"],
"changed": changed,
},
}
# ── QD Score Access ────────────────────────────────────────────────────────
@router.get("/crm/qd/{person_id}", tags=["CRM QD"])
async def get_qd_score(
request: Request,
person_id: str,
user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
"""QD score summary and recent timeseries for a client."""
pool = await _get_pool(request)
async with pool.acquire() as conn:
scores = await conn.fetch(
"""
SELECT score_type, current_value, computed_at, reasoning
FROM intel_qd_scores
WHERE person_id = $1::uuid
AND tenant_id = $2
""",
person_id,
_tenant_scope(user),
)
timeseries = await conn.fetch(
"""
SELECT score_type, value, timestamp, signal_source, delta
FROM intel_qd_timeseries
WHERE person_id = $1::uuid
AND tenant_id = $2
ORDER BY timestamp DESC
LIMIT 50
""",
person_id,
_tenant_scope(user),
)
if not scores:
raise HTTPException(status_code=404, detail=f"No QD scores for client '{person_id}'.")
return {
"status": "ok",
"data": {
"person_id": person_id,
"scores": {
r["score_type"]: {
"current_value": float(r["current_value"]),
"computed_at": r["computed_at"].isoformat() if r["computed_at"] else None,
"reasoning": r["reasoning"],
}
for r in scores
},
"timeseries": [
{
"score_type": r["score_type"],
"value": float(r["value"]),
"timestamp": r["timestamp"].isoformat() if r["timestamp"] else None,
"signal_source": r["signal_source"],
"delta": float(r["delta"]) if r["delta"] else None,
}
for r in timeseries
],
},
}
# ── Oracle Client Data Lens ───────────────────────────────────────────────────
@router.get("/crm/client-data", tags=["CRM Client Data"])
async def list_client_data(
request: Request,
search: str | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
) -> dict[str, Any]:
pool = await _get_pool(request)
params: list[Any] = []
where = "1=1"
if search:
params.append(f"%{search.lower()}%")
where = f"(lower(p.full_name) LIKE ${len(params)} OR lower(COALESCE(p.primary_phone,'')) LIKE ${len(params)} OR lower(COALESCE(p.primary_email,'')) LIKE ${len(params)} OR lower(COALESCE(pi.projects,'')) LIKE ${len(params)})"
params.extend([limit, offset])
async with pool.acquire() as conn:
rows = await conn.fetch(
f"""
WITH interests AS (
SELECT person_id, string_agg(DISTINCT project_name, ', ') AS projects, COUNT(*)::int AS interest_count
FROM crm_property_interests GROUP BY person_id
),
qd AS (
SELECT DISTINCT ON (person_id) person_id, current_value
FROM intel_qd_scores
ORDER BY person_id, current_value DESC, computed_at DESC
)
SELECT p.person_id::text, p.full_name, p.primary_email, p.primary_phone, p.buyer_type,
p.broker_name, p.communication_preference, p.best_contact_time,
l.lead_id::text, l.status::text AS lead_status, l.budget_band, l.urgency,
COALESCE(qd.current_value, p.engagement_score, 0)::float AS qd_score,
lc.last_contact_at, lc.last_channel, lc.days_since_contact,
nba.recommended_action AS next_best_action, nba.priority AS next_action_priority,
COALESCE(pi.projects, '') AS projects, COALESCE(pi.interest_count, 0)::int AS interest_count
FROM crm_people p
LEFT JOIN LATERAL (
SELECT * FROM crm_leads l WHERE l.person_id = p.person_id ORDER BY l.updated_at DESC LIMIT 1
) l ON TRUE
LEFT JOIN interests pi ON pi.person_id = p.person_id
LEFT JOIN qd ON qd.person_id = p.person_id
LEFT JOIN read_last_contacted lc ON lc.person_id = p.person_id
LEFT JOIN read_next_best_action nba ON nba.person_id = p.person_id
WHERE {where}
ORDER BY lc.last_contact_at DESC NULLS LAST, qd_score DESC, p.full_name ASC
LIMIT ${len(params)-1} OFFSET ${len(params)}
""",
*params,
)
return {"status": "ok", "data": [dict(r) for r in rows], "meta": {"count": len(rows), "limit": limit, "offset": offset}}
@router.get("/crm/client-data/{person_id}", tags=["CRM Client Data"])
async def get_client_data(request: Request, person_id: str) -> dict[str, Any]:
pool = await _get_pool(request)
async with pool.acquire() as conn:
base = await conn.fetchrow(
"""
SELECT p.*, l.lead_id::text, l.status::text AS lead_status, l.budget_band, l.urgency,
l.financing_posture, l.timeline_to_decision, l.broker_team,
lc.last_contact_at, lc.last_channel, lc.days_since_contact,
nba.recommended_action, nba.priority AS next_action_priority, nba.rationale,
nba.suggested_channel, nba.due_within_days
FROM crm_people p
LEFT JOIN LATERAL (
SELECT * FROM crm_leads l WHERE l.person_id = p.person_id ORDER BY l.updated_at DESC LIMIT 1
) l ON TRUE
LEFT JOIN read_last_contacted lc ON lc.person_id = p.person_id
LEFT JOIN read_next_best_action nba ON nba.person_id = p.person_id
WHERE p.person_id = $1::uuid
""",
person_id,
)
if not base:
raise HTTPException(status_code=404, detail=f"Client '{person_id}' not found.")
interests = await conn.fetch("SELECT * FROM crm_property_interests WHERE person_id = $1::uuid ORDER BY priority ASC, created_at DESC", person_id)
opportunities = await conn.fetch(
"""
SELECT o.*, ip.project_name FROM crm_opportunities o
JOIN crm_leads l ON l.lead_id = o.lead_id
LEFT JOIN inventory_projects ip ON ip.project_id = o.project_id
WHERE l.person_id = $1::uuid ORDER BY o.updated_at DESC
""",
person_id,
)
facts = await conn.fetch("SELECT * FROM intel_extracted_facts WHERE person_id = $1::uuid ORDER BY extracted_at DESC LIMIT 50", person_id)
qd = await conn.fetch("SELECT * FROM intel_qd_scores WHERE person_id = $1::uuid ORDER BY current_value DESC", person_id)
timeline = await _client_timeline(conn, person_id, 60)
return {
"status": "ok",
"data": {
"profile": dict(base),
"property_interests": [dict(r) for r in interests],
"opportunities": [dict(r) for r in opportunities],
"extracted_facts": [dict(r) for r in facts],
"qd_scores": [dict(r) for r in qd],
"timeline": timeline,
},
}
@router.patch("/crm/client-data/{person_id}", tags=["CRM Client Data"])
async def patch_client_data(request: Request, person_id: str, body: ClientDataPatchRequest) -> dict[str, Any]:
pool = await _get_pool(request)
payload = body.model_dump(exclude_unset=True)
person_fields = {k: payload[k] for k in ("full_name", "primary_email", "primary_phone", "buyer_type", "communication_preference", "best_contact_time") if k in payload}
lead_fields = {k: payload[k] for k in ("budget_band", "urgency") if k in payload}
async with pool.acquire() as conn:
async with conn.transaction():
if person_fields:
sets = ", ".join(f"{key} = ${idx}" for idx, key in enumerate(person_fields, start=1))
await conn.execute(f"UPDATE crm_people SET {sets}, updated_at = NOW() WHERE person_id = ${len(person_fields)+1}::uuid", *person_fields.values(), person_id)
if lead_fields:
lead_id = await conn.fetchval("SELECT lead_id FROM crm_leads WHERE person_id = $1::uuid ORDER BY updated_at DESC LIMIT 1", person_id)
if lead_id:
sets = ", ".join(f"{key} = ${idx}" for idx, key in enumerate(lead_fields, start=1))
await conn.execute(f"UPDATE crm_leads SET {sets}, updated_at = NOW() WHERE lead_id = ${len(lead_fields)+1}::uuid", *lead_fields.values(), str(lead_id))
if "lead_status" in payload:
await conn.execute(
"UPDATE crm_leads SET status = $1::crm_lead_status, updated_at = NOW() WHERE person_id = $2::uuid",
payload["lead_status"],
person_id,
)
return {"status": "ok", "data": {"person_id": person_id, "updated": sorted(payload.keys())}}
@router.get("/crm/client-data/{person_id}/timeline", tags=["CRM Client Data"])
async def get_client_data_timeline(request: Request, person_id: str, limit: int = Query(default=80, ge=1, le=200)) -> dict[str, Any]:
pool = await _get_pool(request)
async with pool.acquire() as conn:
rows = await _client_timeline(conn, person_id, limit)
return {"status": "ok", "data": rows}
@router.post("/crm/client-data/{person_id}/tasks", status_code=201, tags=["CRM Client Data"])
async def create_client_data_task(request: Request, person_id: str, body: CreateReminderRequest) -> dict[str, Any]:
patched = body.model_copy(update={"person_id": person_id})
return await create_task(request, patched)
async def _client_timeline(conn: Any, person_id: str, limit: int) -> list[dict[str, Any]]:
rows = await conn.fetch(
"""
SELECT * FROM (
SELECT i.interaction_id::text AS id, i.channel::text AS type, i.interaction_type AS title,
i.summary, i.happened_at AS date, i.broker_name AS actor
FROM intel_interactions i WHERE i.person_id = $1::uuid
UNION ALL
SELECT m.message_id::text, 'message', m.sender_role, m.message_text, m.delivered_at, m.sender_name
FROM intel_messages m JOIN intel_interactions i ON i.interaction_id = m.interaction_id WHERE i.person_id = $1::uuid
UNION ALL
SELECT r.reminder_id::text, 'reminder', r.title, r.notes, r.due_at, r.priority
FROM intel_reminders r WHERE r.person_id = $1::uuid
UNION ALL
SELECT v.visit_id::text, 'visit', COALESCE(v.outcome_type, 'site_visit'), COALESCE(v.visit_notes, v.broker_notes), v.visited_at, v.broker_name
FROM intel_visits v WHERE v.person_id = $1::uuid
UNION ALL
SELECT q.timeseries_id::text, 'qd', q.score_type, q.signal_source, q.timestamp, q.value::text
FROM intel_qd_timeseries q WHERE q.person_id = $1::uuid
) events
ORDER BY date DESC NULLS LAST
LIMIT $2
""",
person_id,
limit,
)
return [dict(r) for r in rows]