feat: Ipad app production readiness, Colony orchestration, Social posting (#44)
All checks were successful
Production Readiness / backend-contracts (push) Successful in 1m47s
Production Readiness / webos-typecheck (push) Successful in 1m50s
Production Readiness / ipad-parse (push) Successful in 1m34s

#38 Ipad app production readiness, Colony orchestration, Social posting

Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local>
Reviewed-on: #44
This commit was merged in pull request #44.
This commit is contained in:
2026-05-03 18:30:38 +05:30
parent 59d398abc3
commit eeb684b46c
86 changed files with 20349 additions and 1655 deletions

View File

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