forked from sagnik/Project_Velocity
feat: Ipad app production readiness, Colony orchestration, Social posting (#44)
#38 Ipad app production readiness, Colony orchestration, Social posting Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local> Reviewed-on: sagnik/Project_Velocity#44
This commit is contained in:
@@ -77,6 +77,56 @@ CANONICAL_OPPORTUNITY_STAGES = (
|
||||
"closed_won",
|
||||
"closed_lost",
|
||||
)
|
||||
IMPORT_DUPLICATE_POLICIES = ("create_new", "update_existing", "skip_duplicate")
|
||||
CANONICAL_URGENCY_VALUES = ("low", "medium", "high", "critical")
|
||||
CANONICAL_TASK_PRIORITIES = ("low", "normal", "high", "urgent")
|
||||
CANONICAL_BUYER_TYPES = (
|
||||
"end_user",
|
||||
"hni_end_user",
|
||||
"nri_investor",
|
||||
"family_office",
|
||||
"founder_buyer",
|
||||
"broker_referral",
|
||||
"investor",
|
||||
)
|
||||
DREAM_WEAVER_ROOM_TYPES = (
|
||||
("bedroom", "Bedroom", "bed.double"),
|
||||
("living_room", "Living Room", "sofa"),
|
||||
("bathroom", "Bathroom", "drop"),
|
||||
("kitchen", "Kitchen", "refrigerator"),
|
||||
("dining_room", "Dining Room", "fork.knife"),
|
||||
("home_office", "Office", "desktopcomputer"),
|
||||
("hallway", "Hallway", "door.left.hand.open"),
|
||||
("balcony", "Balcony", "sun.max"),
|
||||
)
|
||||
|
||||
|
||||
def _label_for_vocab(value: str) -> str:
|
||||
return value.replace("_", " ").title()
|
||||
|
||||
|
||||
def _vocab_options(values: tuple[str, ...], descriptions: dict[str, str] | None = None) -> list[dict[str, str]]:
|
||||
descriptions = descriptions or {}
|
||||
return [
|
||||
{
|
||||
"value": value,
|
||||
"label": _label_for_vocab(value),
|
||||
"description": descriptions.get(value, ""),
|
||||
}
|
||||
for value in values
|
||||
]
|
||||
|
||||
|
||||
def _room_type_options() -> list[dict[str, str]]:
|
||||
return [
|
||||
{
|
||||
"value": value,
|
||||
"label": label,
|
||||
"description": "",
|
||||
"icon": icon,
|
||||
}
|
||||
for value, label, icon in DREAM_WEAVER_ROOM_TYPES
|
||||
]
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
@@ -176,12 +226,53 @@ def _opportunity_payload(row) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
@router.get("/crm/vocabularies", tags=["CRM Vocabularies"])
|
||||
async def get_crm_vocabularies(
|
||||
request: Request,
|
||||
user: UserPrincipal = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Canonical business vocabularies for native clients.
|
||||
|
||||
The iPad must not own CRM funnel semantics, duplicate merge policy values,
|
||||
task priorities, buyer personas, or Dream Weaver room vocabularies. This
|
||||
endpoint gives authenticated clients a single backend-owned contract.
|
||||
"""
|
||||
await _get_pool(request)
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"lead_statuses": _vocab_options(CANONICAL_LEAD_STAGES),
|
||||
"urgencies": _vocab_options(CANONICAL_URGENCY_VALUES),
|
||||
"buyer_types": _vocab_options(CANONICAL_BUYER_TYPES),
|
||||
"task_priorities": _vocab_options(CANONICAL_TASK_PRIORITIES),
|
||||
"lead_stages": _vocab_options(CANONICAL_LEAD_STAGES),
|
||||
"opportunity_stages": _vocab_options(CANONICAL_OPPORTUNITY_STAGES),
|
||||
"import_duplicate_policies": _vocab_options(
|
||||
IMPORT_DUPLICATE_POLICIES,
|
||||
{
|
||||
"create_new": "Create a new canonical CRM person from the approved row.",
|
||||
"update_existing": "Merge approved fields into the strongest duplicate candidate.",
|
||||
"skip_duplicate": "Skip canonical create/update for this approved row.",
|
||||
},
|
||||
),
|
||||
"dream_weaver_room_types": _room_type_options(),
|
||||
},
|
||||
"meta": {
|
||||
"tenant_id": _tenant_scope(user),
|
||||
"source_of_truth": "backend_canonical_crm_vocabulary",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── Models ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class ProposalApprovalRequest(BaseModel):
|
||||
proposal_id: str
|
||||
decision: str = Field(..., pattern="^(approved|rejected|needs_more_info)$")
|
||||
notes: str = Field(default="", max_length=2000)
|
||||
field_overrides: dict[str, Any] = Field(default_factory=dict)
|
||||
duplicate_policy: str = Field(default="create_new", pattern="^(create_new|update_existing|skip_duplicate)$")
|
||||
|
||||
|
||||
class CreatePersonRequest(BaseModel):
|
||||
@@ -228,6 +319,139 @@ class ClientDataPatchRequest(BaseModel):
|
||||
urgency: str | None = Field(default=None, max_length=64)
|
||||
|
||||
|
||||
IMPORT_VALIDATION_FIELDS = (
|
||||
"full_name",
|
||||
"primary_email",
|
||||
"primary_phone",
|
||||
"buyer_type",
|
||||
"budget_band",
|
||||
"project_name",
|
||||
)
|
||||
|
||||
|
||||
def _clean_import_value(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _digits_only(value: str | None) -> str:
|
||||
return "".join(ch for ch in (value or "") if ch.isdigit())
|
||||
|
||||
|
||||
def _validate_import_canonical(canonical: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
issues: list[dict[str, Any]] = []
|
||||
full_name = _clean_import_value(canonical.get("full_name"))
|
||||
email = _clean_import_value(canonical.get("primary_email"))
|
||||
phone = _clean_import_value(canonical.get("primary_phone"))
|
||||
|
||||
if not full_name:
|
||||
issues.append({
|
||||
"field": "full_name",
|
||||
"severity": "error",
|
||||
"message": "Full name is required before commit.",
|
||||
})
|
||||
elif len(full_name) < 2:
|
||||
issues.append({
|
||||
"field": "full_name",
|
||||
"severity": "warning",
|
||||
"message": "Full name is unusually short.",
|
||||
})
|
||||
|
||||
if email and ("@" not in email or "." not in email.split("@")[-1]):
|
||||
issues.append({
|
||||
"field": "primary_email",
|
||||
"severity": "error",
|
||||
"message": "Email must look like a valid address.",
|
||||
})
|
||||
|
||||
if phone and len(_digits_only(phone)) < 7:
|
||||
issues.append({
|
||||
"field": "primary_phone",
|
||||
"severity": "error",
|
||||
"message": "Phone number must contain at least 7 digits.",
|
||||
})
|
||||
|
||||
if not email and not phone:
|
||||
issues.append({
|
||||
"field": "primary_phone",
|
||||
"severity": "warning",
|
||||
"message": "No phone or email is present; future dedupe and outreach quality will be limited.",
|
||||
})
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def _crm_person_candidate(row: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"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"],
|
||||
"source_confidence": float(row["source_confidence"]) if row["source_confidence"] is not None else None,
|
||||
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
|
||||
"updated_at": row["updated_at"].isoformat() if row["updated_at"] else None,
|
||||
"match_reason": row["match_reason"],
|
||||
"match_score": int(row["match_score"]),
|
||||
}
|
||||
|
||||
|
||||
def _proposal_field_diff(canonical: dict[str, Any], existing: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
fields = sorted(set(IMPORT_VALIDATION_FIELDS).union(canonical.keys()))
|
||||
diffs: list[dict[str, Any]] = []
|
||||
for field in fields:
|
||||
proposed = _clean_import_value(canonical.get(field))
|
||||
current = _clean_import_value(existing.get(field)) if existing else None
|
||||
if proposed is None and current is None:
|
||||
continue
|
||||
diffs.append({
|
||||
"field": field,
|
||||
"proposed": proposed,
|
||||
"existing": current,
|
||||
"changed": proposed != current,
|
||||
})
|
||||
return diffs
|
||||
|
||||
|
||||
async def _find_duplicate_person(conn, tenant_id: str, canonical: dict[str, Any]) -> Any | None:
|
||||
email = _clean_import_value(canonical.get("primary_email"))
|
||||
phone = _clean_import_value(canonical.get("primary_phone"))
|
||||
full_name = _clean_import_value(canonical.get("full_name"))
|
||||
return await conn.fetchrow(
|
||||
"""
|
||||
SELECT p.person_id, p.full_name, p.primary_email, p.primary_phone, p.buyer_type,
|
||||
p.source_confidence, p.created_at, p.updated_at,
|
||||
CASE
|
||||
WHEN $2::text IS NOT NULL AND LOWER(p.primary_email) = LOWER($2::text) THEN 'email'
|
||||
WHEN $3::text IS NOT NULL AND regexp_replace(COALESCE(p.primary_phone, ''), '\\D', '', 'g') = regexp_replace($3::text, '\\D', '', 'g') THEN 'phone'
|
||||
WHEN $4::text IS NOT NULL AND LOWER(p.full_name) = LOWER($4::text) THEN 'name'
|
||||
ELSE 'fuzzy'
|
||||
END AS match_reason,
|
||||
CASE
|
||||
WHEN $2::text IS NOT NULL AND LOWER(p.primary_email) = LOWER($2::text) THEN 100
|
||||
WHEN $3::text IS NOT NULL AND regexp_replace(COALESCE(p.primary_phone, ''), '\\D', '', 'g') = regexp_replace($3::text, '\\D', '', 'g') THEN 95
|
||||
WHEN $4::text IS NOT NULL AND LOWER(p.full_name) = LOWER($4::text) THEN 70
|
||||
ELSE 50
|
||||
END AS match_score
|
||||
FROM crm_people p
|
||||
WHERE p.tenant_id = $1
|
||||
AND (
|
||||
($2::text IS NOT NULL AND LOWER(p.primary_email) = LOWER($2::text))
|
||||
OR ($3::text IS NOT NULL AND regexp_replace(COALESCE(p.primary_phone, ''), '\\D', '', 'g') = regexp_replace($3::text, '\\D', '', 'g'))
|
||||
OR ($4::text IS NOT NULL AND LOWER(p.full_name) = LOWER($4::text))
|
||||
)
|
||||
ORDER BY match_score DESC, p.updated_at DESC NULLS LAST
|
||||
LIMIT 1
|
||||
""",
|
||||
tenant_id,
|
||||
email,
|
||||
phone,
|
||||
full_name,
|
||||
)
|
||||
|
||||
|
||||
class UpdateReminderRequest(BaseModel):
|
||||
status: str = Field(..., min_length=1, max_length=32)
|
||||
due_at: str | None = None
|
||||
@@ -419,6 +643,121 @@ async def get_import_batch(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/crm/imports/{batch_id}/workbench", tags=["CRM Imports"])
|
||||
async def get_import_workbench(
|
||||
request: Request,
|
||||
batch_id: str,
|
||||
user: UserPrincipal = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Return enterprise review diagnostics for an import batch.
|
||||
|
||||
This endpoint is intentionally read-only. It lets iPad/WebOS operators see
|
||||
per-field validation, duplicate candidates, and row-level diffs before
|
||||
committing approved proposals into canonical CRM tables.
|
||||
"""
|
||||
pool = await _get_pool(request)
|
||||
tenant_id = _tenant_scope(user)
|
||||
async with pool.acquire() as conn:
|
||||
batch_exists = await conn.fetchval(
|
||||
"SELECT EXISTS (SELECT 1 FROM workflow_import_batches WHERE batch_id = $1::uuid AND tenant_id = $2)",
|
||||
batch_id,
|
||||
tenant_id,
|
||||
)
|
||||
if not batch_exists:
|
||||
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_id,
|
||||
batch_id,
|
||||
)
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
duplicate_count = 0
|
||||
validation_error_count = 0
|
||||
validation_warning_count = 0
|
||||
|
||||
for proposal in proposal_rows:
|
||||
payload = dict(proposal["proposal_payload"] or {})
|
||||
canonical = dict(payload.get("canonical_payload") or {})
|
||||
email = _clean_import_value(canonical.get("primary_email"))
|
||||
phone = _clean_import_value(canonical.get("primary_phone"))
|
||||
full_name = _clean_import_value(canonical.get("full_name"))
|
||||
|
||||
duplicate_candidates = await conn.fetch(
|
||||
"""
|
||||
SELECT p.person_id, p.full_name, p.primary_email, p.primary_phone, p.buyer_type,
|
||||
p.source_confidence, p.created_at, p.updated_at,
|
||||
CASE
|
||||
WHEN $2::text IS NOT NULL AND LOWER(p.primary_email) = LOWER($2::text) THEN 'email'
|
||||
WHEN $3::text IS NOT NULL AND regexp_replace(COALESCE(p.primary_phone, ''), '\\D', '', 'g') = regexp_replace($3::text, '\\D', '', 'g') THEN 'phone'
|
||||
WHEN $4::text IS NOT NULL AND LOWER(p.full_name) = LOWER($4::text) THEN 'name'
|
||||
ELSE 'fuzzy'
|
||||
END AS match_reason,
|
||||
CASE
|
||||
WHEN $2::text IS NOT NULL AND LOWER(p.primary_email) = LOWER($2::text) THEN 100
|
||||
WHEN $3::text IS NOT NULL AND regexp_replace(COALESCE(p.primary_phone, ''), '\\D', '', 'g') = regexp_replace($3::text, '\\D', '', 'g') THEN 95
|
||||
WHEN $4::text IS NOT NULL AND LOWER(p.full_name) = LOWER($4::text) THEN 70
|
||||
ELSE 50
|
||||
END AS match_score
|
||||
FROM crm_people p
|
||||
WHERE p.tenant_id = $1
|
||||
AND (
|
||||
($2::text IS NOT NULL AND LOWER(p.primary_email) = LOWER($2::text))
|
||||
OR ($3::text IS NOT NULL AND regexp_replace(COALESCE(p.primary_phone, ''), '\\D', '', 'g') = regexp_replace($3::text, '\\D', '', 'g'))
|
||||
OR ($4::text IS NOT NULL AND LOWER(p.full_name) = LOWER($4::text))
|
||||
)
|
||||
ORDER BY match_score DESC, p.updated_at DESC NULLS LAST
|
||||
LIMIT 5
|
||||
""",
|
||||
tenant_id,
|
||||
email,
|
||||
phone,
|
||||
full_name,
|
||||
)
|
||||
candidates = [_crm_person_candidate(row) for row in duplicate_candidates]
|
||||
existing = candidates[0] if candidates else None
|
||||
validation = _validate_import_canonical(canonical)
|
||||
validation_error_count += sum(1 for issue in validation if issue["severity"] == "error")
|
||||
validation_warning_count += sum(1 for issue in validation if issue["severity"] == "warning")
|
||||
if candidates:
|
||||
duplicate_count += 1
|
||||
|
||||
rows.append({
|
||||
"proposal_id": str(proposal["action_id"]),
|
||||
"row_number": payload.get("row_number"),
|
||||
"status": proposal["status"],
|
||||
"confidence": float(proposal["confidence"]) if proposal["confidence"] else 0.0,
|
||||
"validation": validation,
|
||||
"duplicate_candidates": candidates,
|
||||
"duplicate_policy": payload.get("duplicate_policy") or ("update_existing" if candidates else "create_new"),
|
||||
"field_diffs": _proposal_field_diff(canonical, existing),
|
||||
})
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"batch_id": batch_id,
|
||||
"summary": {
|
||||
"proposal_count": len(rows),
|
||||
"duplicate_count": duplicate_count,
|
||||
"validation_error_count": validation_error_count,
|
||||
"validation_warning_count": validation_warning_count,
|
||||
},
|
||||
"rows": rows,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.put("/crm/imports/{batch_id}/review-proposal", tags=["CRM Imports"])
|
||||
async def review_proposal(
|
||||
request: Request,
|
||||
@@ -435,7 +774,7 @@ async def review_proposal(
|
||||
async with pool.acquire() as conn:
|
||||
action = await conn.fetchrow(
|
||||
"""
|
||||
SELECT action_id, confidence, approval_required
|
||||
SELECT action_id, confidence, approval_required, proposal_payload
|
||||
FROM workflow_actions
|
||||
WHERE action_id = $1::uuid
|
||||
AND tenant_id = $2
|
||||
@@ -449,7 +788,41 @@ async def review_proposal(
|
||||
raise HTTPException(status_code=404, detail="Proposal not found.")
|
||||
|
||||
decision_id = str(uuid.uuid4())
|
||||
new_status = "approved" if body.decision == "approved" else "rejected"
|
||||
new_status = {
|
||||
"approved": "approved",
|
||||
"rejected": "rejected",
|
||||
"needs_more_info": "review_required",
|
||||
}[body.decision]
|
||||
proposal_payload = dict(action["proposal_payload"] or {})
|
||||
if body.field_overrides:
|
||||
canonical_payload = dict(proposal_payload.get("canonical_payload") or {})
|
||||
cleaned_overrides = {
|
||||
key: value
|
||||
for key, value in body.field_overrides.items()
|
||||
if key and value is not None and str(value).strip()
|
||||
}
|
||||
canonical_payload.update(cleaned_overrides)
|
||||
if cleaned_overrides:
|
||||
missing_required = [
|
||||
field for field in proposal_payload.get("missing_required", [])
|
||||
if field not in cleaned_overrides
|
||||
]
|
||||
unresolved_fields = [
|
||||
field for field in proposal_payload.get("unresolved_fields", [])
|
||||
if field not in cleaned_overrides
|
||||
]
|
||||
proposal_payload["canonical_payload"] = canonical_payload
|
||||
proposal_payload["missing_required"] = missing_required
|
||||
proposal_payload["unresolved_fields"] = unresolved_fields
|
||||
proposal_payload["remediation"] = {
|
||||
"source": "ipad_import_workbench",
|
||||
"field_overrides": cleaned_overrides,
|
||||
"updated_at": _now(),
|
||||
"updated_by": user.user_id,
|
||||
}
|
||||
proposal_payload["duplicate_policy"] = body.duplicate_policy
|
||||
proposal_payload["duplicate_policy_updated_at"] = _now()
|
||||
proposal_payload["duplicate_policy_updated_by"] = user.user_id
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
@@ -465,11 +838,12 @@ async def review_proposal(
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE workflow_actions
|
||||
SET status = $1::wf_status, updated_at = NOW()
|
||||
WHERE action_id = $2::uuid
|
||||
AND tenant_id = $3
|
||||
SET status = $1::wf_status, proposal_payload = $2::jsonb, updated_at = NOW()
|
||||
WHERE action_id = $3::uuid
|
||||
AND tenant_id = $4
|
||||
""",
|
||||
new_status,
|
||||
json.dumps(proposal_payload),
|
||||
body.proposal_id,
|
||||
_tenant_scope(user),
|
||||
)
|
||||
@@ -519,30 +893,91 @@ async def commit_approved_proposals(
|
||||
try:
|
||||
payload = row["proposal_payload"]
|
||||
canonical = payload.get("canonical_payload", {})
|
||||
if not canonical.get("full_name"):
|
||||
validation_errors = [
|
||||
issue for issue in _validate_import_canonical(canonical)
|
||||
if issue["severity"] == "error"
|
||||
]
|
||||
if validation_errors:
|
||||
skipped += 1
|
||||
errors.append(
|
||||
f"Proposal {row['action_id']}: "
|
||||
+ "; ".join(issue["message"] for issue in validation_errors)
|
||||
)
|
||||
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()
|
||||
duplicate_policy = payload.get("duplicate_policy") or "create_new"
|
||||
if duplicate_policy not in IMPORT_DUPLICATE_POLICIES:
|
||||
duplicate_policy = "create_new"
|
||||
|
||||
duplicate_person = await _find_duplicate_person(conn, _tenant_scope(user), canonical)
|
||||
if duplicate_person and duplicate_policy == "skip_duplicate":
|
||||
skipped += 1
|
||||
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),
|
||||
)
|
||||
continue
|
||||
|
||||
if duplicate_person and duplicate_policy == "update_existing":
|
||||
person_id = str(duplicate_person["person_id"])
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE crm_people
|
||||
SET full_name = COALESCE($3, full_name),
|
||||
primary_email = COALESCE($4, primary_email),
|
||||
primary_phone = COALESCE($5, primary_phone),
|
||||
buyer_type = COALESCE($6, buyer_type),
|
||||
source_confidence = GREATEST(COALESCE(source_confidence, 0), $7),
|
||||
metadata_json = COALESCE(metadata_json, '{}'::jsonb) || $8::jsonb,
|
||||
updated_at = NOW()
|
||||
WHERE person_id = $1::uuid
|
||||
AND tenant_id = $2
|
||||
""",
|
||||
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"),
|
||||
"duplicate_policy": duplicate_policy,
|
||||
"merged_from_import": True,
|
||||
}),
|
||||
)
|
||||
else:
|
||||
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"),
|
||||
"duplicate_policy": duplicate_policy,
|
||||
}),
|
||||
)
|
||||
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())
|
||||
@@ -985,6 +1420,11 @@ async def create_task(
|
||||
"""Create a reminder / follow-up task."""
|
||||
pool = await _get_pool(request)
|
||||
reminder_id = str(uuid.uuid4())
|
||||
next_priority = _normalize_choice(
|
||||
body.priority,
|
||||
allowed=CANONICAL_TASK_PRIORITIES,
|
||||
field_name="task priority",
|
||||
)
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
due_dt = _parse_optional_datetime(body.due_at, field_name="due_at")
|
||||
@@ -1035,7 +1475,7 @@ async def create_task(
|
||||
body.title,
|
||||
body.notes,
|
||||
due_dt,
|
||||
body.priority,
|
||||
next_priority,
|
||||
)
|
||||
|
||||
return {"status": "ok", "data": {"reminder_id": reminder_id, "title": body.title}}
|
||||
@@ -1362,13 +1802,26 @@ async def list_client_data(
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> dict[str, Any]:
|
||||
pool = await _get_pool(request)
|
||||
params: list[Any] = []
|
||||
filter_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])
|
||||
filter_params.append(f"%{search.lower()}%")
|
||||
where = f"(lower(p.full_name) LIKE ${len(filter_params)} OR lower(COALESCE(p.primary_phone,'')) LIKE ${len(filter_params)} OR lower(COALESCE(p.primary_email,'')) LIKE ${len(filter_params)} OR lower(COALESCE(pi.projects,'')) LIKE ${len(filter_params)})"
|
||||
row_params = [*filter_params, limit, offset]
|
||||
async with pool.acquire() as conn:
|
||||
total_count = await conn.fetchval(
|
||||
f"""
|
||||
WITH interests AS (
|
||||
SELECT person_id, string_agg(DISTINCT project_name, ', ') AS projects
|
||||
FROM crm_property_interests GROUP BY person_id
|
||||
)
|
||||
SELECT COUNT(*)::int
|
||||
FROM crm_people p
|
||||
LEFT JOIN interests pi ON pi.person_id = p.person_id
|
||||
WHERE {where}
|
||||
""",
|
||||
*filter_params,
|
||||
)
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
WITH interests AS (
|
||||
@@ -1397,11 +1850,21 @@ async def list_client_data(
|
||||
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)}
|
||||
LIMIT ${len(row_params)-1} OFFSET ${len(row_params)}
|
||||
""",
|
||||
*params,
|
||||
*row_params,
|
||||
)
|
||||
return {"status": "ok", "data": [dict(r) for r in rows], "meta": {"count": len(rows), "limit": limit, "offset": offset}}
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": [dict(r) for r in rows],
|
||||
"meta": {
|
||||
"count": len(rows),
|
||||
"total_count": total_count or 0,
|
||||
"has_more": offset + len(rows) < (total_count or 0),
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/crm/client-data/{person_id}", tags=["CRM Client Data"])
|
||||
@@ -1454,26 +1917,66 @@ async def get_client_data(request: Request, person_id: str) -> dict[str, Any]:
|
||||
|
||||
|
||||
@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]:
|
||||
async def patch_client_data(
|
||||
request: Request,
|
||||
person_id: str,
|
||||
body: ClientDataPatchRequest,
|
||||
user: UserPrincipal = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
pool = await _get_pool(request)
|
||||
payload = body.model_dump(exclude_unset=True)
|
||||
tenant_id = _tenant_scope(user)
|
||||
if "lead_status" in payload and payload["lead_status"] is not None:
|
||||
payload["lead_status"] = _normalize_choice(
|
||||
payload["lead_status"],
|
||||
allowed=CANONICAL_LEAD_STAGES,
|
||||
field_name="lead status",
|
||||
)
|
||||
if "urgency" in payload and payload["urgency"] is not None:
|
||||
payload["urgency"] = _normalize_choice(
|
||||
payload["urgency"],
|
||||
allowed=CANONICAL_URGENCY_VALUES,
|
||||
field_name="urgency",
|
||||
)
|
||||
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():
|
||||
person_exists = await conn.fetchval(
|
||||
"SELECT EXISTS (SELECT 1 FROM crm_people WHERE person_id = $1::uuid AND tenant_id = $2)",
|
||||
person_id,
|
||||
tenant_id,
|
||||
)
|
||||
if not person_exists:
|
||||
raise HTTPException(status_code=404, detail=f"Client '{person_id}' not found.")
|
||||
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)
|
||||
await conn.execute(
|
||||
f"UPDATE crm_people SET {sets}, updated_at = NOW() WHERE person_id = ${len(person_fields)+1}::uuid AND tenant_id = ${len(person_fields)+2}",
|
||||
*person_fields.values(),
|
||||
person_id,
|
||||
tenant_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)
|
||||
lead_id = await conn.fetchval(
|
||||
"SELECT lead_id FROM crm_leads WHERE person_id = $1::uuid AND tenant_id = $2 ORDER BY updated_at DESC LIMIT 1",
|
||||
person_id,
|
||||
tenant_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))
|
||||
await conn.execute(
|
||||
f"UPDATE crm_leads SET {sets}, updated_at = NOW() WHERE lead_id = ${len(lead_fields)+1}::uuid AND tenant_id = ${len(lead_fields)+2}",
|
||||
*lead_fields.values(),
|
||||
str(lead_id),
|
||||
tenant_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",
|
||||
"UPDATE crm_leads SET status = $1::crm_lead_status, updated_at = NOW() WHERE person_id = $2::uuid AND tenant_id = $3",
|
||||
payload["lead_status"],
|
||||
person_id,
|
||||
tenant_id,
|
||||
)
|
||||
return {"status": "ok", "data": {"person_id": person_id, "updated": sorted(payload.keys())}}
|
||||
|
||||
@@ -1487,9 +1990,14 @@ async def get_client_data_timeline(request: Request, person_id: str, limit: int
|
||||
|
||||
|
||||
@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]:
|
||||
async def create_client_data_task(
|
||||
request: Request,
|
||||
person_id: str,
|
||||
body: CreateReminderRequest,
|
||||
user: UserPrincipal = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
patched = body.model_copy(update={"person_id": person_id})
|
||||
return await create_task(request, patched)
|
||||
return await create_task(request, patched, user)
|
||||
|
||||
|
||||
async def _client_timeline(conn: Any, person_id: str, limit: int) -> list[dict[str, Any]]:
|
||||
|
||||
Reference in New Issue
Block a user