forked from sagnik/Project_Velocity
#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
1225 lines
52 KiB
Python
1225 lines
52 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Seed realistic, idempotent iPad investor-demo data into the current operator tenant.
|
|
|
|
The script writes only canonical Velocity domains used by the iPad app:
|
|
crm_*, intel_*, workflow_*, inventory_*, mobile-edge events, and calendar events.
|
|
Rows are tagged with metadata_json/source identifiers so they are auditable and safe
|
|
to re-run without duplication.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import re
|
|
import uuid
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from dotenv import load_dotenv
|
|
|
|
try:
|
|
import asyncpg
|
|
except ModuleNotFoundError: # pragma: no cover - exercised by operator environment
|
|
asyncpg = None # type: ignore[assignment]
|
|
|
|
|
|
SEED_SOURCE = "velocity_ipad_investor_demo_2026_04"
|
|
DEFAULT_OPERATOR_EMAIL = "sayan@desineuron.in"
|
|
NAMESPACE = uuid.uuid5(uuid.NAMESPACE_URL, "https://desineuron.in/project-velocity/ipad-investor-demo")
|
|
DEMO_CLIENT_TARGET_COUNT = 50
|
|
|
|
|
|
def _load_env() -> None:
|
|
repo_root = Path(__file__).resolve().parents[2]
|
|
for candidate in (repo_root / "backend" / ".env", repo_root / ".env"):
|
|
if candidate.exists():
|
|
load_dotenv(candidate, override=False)
|
|
load_dotenv(override=False)
|
|
|
|
|
|
def _stable_uuid(tenant_id: str, key: str) -> str:
|
|
return str(uuid.uuid5(NAMESPACE, f"{tenant_id}:{key}"))
|
|
|
|
|
|
def _json(value: Any) -> str:
|
|
return json.dumps(value, separators=(",", ":"), ensure_ascii=True)
|
|
|
|
|
|
def _db_kwargs() -> dict[str, Any]:
|
|
if os.getenv("DATABASE_URL"):
|
|
return {"dsn": os.environ["DATABASE_URL"]}
|
|
required = ["VELOCITY_DB_NAME", "VELOCITY_DB_USER", "VELOCITY_DB_PASSWORD"]
|
|
missing = [key for key in required if not os.getenv(key)]
|
|
if missing:
|
|
raise RuntimeError(
|
|
"Missing database configuration: "
|
|
+ ", ".join(missing)
|
|
+ ". Set DATABASE_URL or VELOCITY_DB_* variables before running the seed."
|
|
)
|
|
return {
|
|
"host": os.getenv("VELOCITY_DB_HOST", "localhost"),
|
|
"port": int(os.getenv("VELOCITY_DB_PORT", "5432")),
|
|
"database": os.environ["VELOCITY_DB_NAME"],
|
|
"user": os.environ["VELOCITY_DB_USER"],
|
|
"password": os.environ["VELOCITY_DB_PASSWORD"],
|
|
}
|
|
|
|
|
|
def _expected_counts() -> dict[str, int]:
|
|
return {
|
|
"people": len(DEMO_CLIENTS),
|
|
"leads": len(DEMO_CLIENTS),
|
|
"projects": len(PROJECTS),
|
|
"properties": len(PROJECTS),
|
|
"property_media": len(PROJECTS) * 2,
|
|
"interests": len(DEMO_CLIENTS),
|
|
"opportunities": len(DEMO_CLIENTS),
|
|
"scores": len(DEMO_CLIENTS) * 3,
|
|
"interactions": len(DEMO_CLIENTS) * 3,
|
|
"edge_events": len(DEMO_CLIENTS),
|
|
"reminders": len(DEMO_CLIENTS),
|
|
"calendar_events": len(DEMO_CLIENTS),
|
|
"insight_recommendations": len(DEMO_CLIENTS),
|
|
"comms_threads": len(DEMO_CLIENTS),
|
|
"comms_messages": len(DEMO_CLIENTS) * 3,
|
|
"comms_call_logs": len(DEMO_CLIENTS),
|
|
"import_batches": 1,
|
|
"import_proposals": 3,
|
|
}
|
|
|
|
|
|
async def _resolve_operator(conn: asyncpg.Connection, email: str, tenant_override: str | None) -> tuple[str, str | None]:
|
|
if tenant_override:
|
|
user_id = await conn.fetchval(
|
|
"SELECT id::text FROM users_and_roles WHERE email = $1 AND tenant_id = $2 LIMIT 1",
|
|
email,
|
|
tenant_override,
|
|
)
|
|
return tenant_override, user_id
|
|
|
|
row = await conn.fetchrow(
|
|
"""
|
|
SELECT id::text AS user_id, tenant_id
|
|
FROM users_and_roles
|
|
WHERE email = $1
|
|
AND is_active = TRUE
|
|
ORDER BY last_login DESC NULLS LAST, created_at DESC
|
|
LIMIT 1
|
|
""",
|
|
email,
|
|
)
|
|
if row:
|
|
return row["tenant_id"], row["user_id"]
|
|
tenant_id = os.getenv("VELOCITY_DEFAULT_TENANT_ID", "tenant_velocity")
|
|
return tenant_id, None
|
|
|
|
|
|
DEMO_CLIENTS = [
|
|
{
|
|
"key": "meera-sen",
|
|
"name": "Meera Sen",
|
|
"email": "meera.sen.investor@demo.desineuron.in",
|
|
"phone": "+91-98745-11820",
|
|
"buyer_type": "hni_end_user",
|
|
"city": "Kolkata",
|
|
"nationality": "Indian",
|
|
"persona": ["family_upgrader", "waterfront_preference"],
|
|
"budget": "12-18 Cr",
|
|
"urgency": "high",
|
|
"status": "qualified",
|
|
"project": "Atri Aqua Sky Residences",
|
|
"configuration": "4BHK Sky Villa",
|
|
"unit": "Tower A / High floor",
|
|
"budget_min": 120000000,
|
|
"budget_max": 180000000,
|
|
"stage": "proposal",
|
|
"value": 154000000,
|
|
"probability": 72,
|
|
"next_action": "Share revised payment schedule and club-deck walkthrough slots.",
|
|
"scores": (0.91, 0.86, 0.88),
|
|
},
|
|
{
|
|
"key": "arjun-malhotra",
|
|
"name": "Arjun Malhotra",
|
|
"email": "arjun.malhotra.nri@demo.desineuron.in",
|
|
"phone": "+91-98102-44591",
|
|
"buyer_type": "nri_investor",
|
|
"city": "Dubai",
|
|
"nationality": "Indian",
|
|
"persona": ["nri_portfolio_buyer", "rental_yield_focused"],
|
|
"budget": "8-12 Cr",
|
|
"urgency": "medium",
|
|
"status": "site_visit_scheduled",
|
|
"project": "Alipore Azure Residences",
|
|
"configuration": "3BHK Signature",
|
|
"unit": "Tower B / 21st floor",
|
|
"budget_min": 80000000,
|
|
"budget_max": 120000000,
|
|
"stage": "site_visit",
|
|
"value": 98000000,
|
|
"probability": 61,
|
|
"next_action": "Coordinate Saturday family video walkthrough and NRI remittance checklist.",
|
|
"scores": (0.82, 0.79, 0.63),
|
|
},
|
|
{
|
|
"key": "devika-roy",
|
|
"name": "Devika Roy",
|
|
"email": "devika.roy.familyoffice@demo.desineuron.in",
|
|
"phone": "+91-99033-28741",
|
|
"buyer_type": "family_office",
|
|
"city": "Mumbai",
|
|
"nationality": "Indian",
|
|
"persona": ["portfolio_allocator", "low_visibility_buyer"],
|
|
"budget": "20-30 Cr",
|
|
"urgency": "critical",
|
|
"status": "negotiation",
|
|
"project": "Victoria Gardens Private Residences",
|
|
"configuration": "Penthouse Duplex",
|
|
"unit": "Private elevator stack",
|
|
"budget_min": 200000000,
|
|
"budget_max": 300000000,
|
|
"stage": "negotiation",
|
|
"value": 265000000,
|
|
"probability": 78,
|
|
"next_action": "Prepare founder-level commercial note before family office review.",
|
|
"scores": (0.95, 0.91, 0.96),
|
|
},
|
|
{
|
|
"key": "rohan-kapoor",
|
|
"name": "Rohan Kapoor",
|
|
"email": "rohan.kapoor.startup@demo.desineuron.in",
|
|
"phone": "+91-98311-73654",
|
|
"buyer_type": "founder_buyer",
|
|
"city": "Bengaluru",
|
|
"nationality": "Indian",
|
|
"persona": ["founder_liquidity_event", "design_led_buyer"],
|
|
"budget": "6-10 Cr",
|
|
"urgency": "high",
|
|
"status": "contacted",
|
|
"project": "Salt Lake Atelier Homes",
|
|
"configuration": "3.5BHK Garden Home",
|
|
"unit": "Podium garden facing",
|
|
"budget_min": 60000000,
|
|
"budget_max": 100000000,
|
|
"stage": "qualified",
|
|
"value": 83500000,
|
|
"probability": 54,
|
|
"next_action": "Send design moodboard and schedule Dream Weaver room concept.",
|
|
"scores": (0.76, 0.83, 0.81),
|
|
},
|
|
{
|
|
"key": "saira-hussain",
|
|
"name": "Saira Hussain",
|
|
"email": "saira.hussain.doctor@demo.desineuron.in",
|
|
"phone": "+91-97482-66019",
|
|
"buyer_type": "end_user",
|
|
"city": "Kolkata",
|
|
"nationality": "Indian",
|
|
"persona": ["quiet_luxury", "school_proximity"],
|
|
"budget": "4-6 Cr",
|
|
"urgency": "medium",
|
|
"status": "site_visited",
|
|
"project": "Ballygunge Meridian",
|
|
"configuration": "3BHK",
|
|
"unit": "South-east corner",
|
|
"budget_min": 40000000,
|
|
"budget_max": 60000000,
|
|
"stage": "proposal",
|
|
"value": 52000000,
|
|
"probability": 66,
|
|
"next_action": "Share school-route comparison and revised parking availability.",
|
|
"scores": (0.74, 0.68, 0.62),
|
|
},
|
|
{
|
|
"key": "vikram-jalan",
|
|
"name": "Vikram Jalan",
|
|
"email": "vikram.jalan.broker@demo.desineuron.in",
|
|
"phone": "+91-98300-91274",
|
|
"buyer_type": "broker_referral",
|
|
"city": "Kolkata",
|
|
"nationality": "Indian",
|
|
"persona": ["broker_network", "bulk_referral_potential"],
|
|
"budget": "15-25 Cr",
|
|
"urgency": "high",
|
|
"status": "booking_initiated",
|
|
"project": "Atri Aqua Sky Residences",
|
|
"configuration": "4BHK River Deck",
|
|
"unit": "Two adjacent units",
|
|
"budget_min": 150000000,
|
|
"budget_max": 250000000,
|
|
"stage": "booking",
|
|
"value": 212000000,
|
|
"probability": 84,
|
|
"next_action": "Confirm booking amount routing and broker mandate documentation.",
|
|
"scores": (0.88, 0.77, 0.89),
|
|
},
|
|
]
|
|
|
|
|
|
PROJECTS = {
|
|
"Atri Aqua Sky Residences": {
|
|
"developer": "Atri Group",
|
|
"micro_market": "Batanagar Riverside",
|
|
"address": "Maheshtala Riverside Corridor, Kolkata",
|
|
"property_type": "apartment",
|
|
"location": {"city": "Kolkata", "district": "Maheshtala", "lat": 22.4981, "lng": 88.2291},
|
|
"price_bands": [
|
|
{"unitType": "3BHK", "minINR": 72000000, "maxINR": 98000000},
|
|
{"unitType": "4BHK Sky Villa", "minINR": 130000000, "maxINR": 190000000},
|
|
],
|
|
"unit_mix": [{"bedrooms": 3, "count": 42, "sizeSqft": 2450}, {"bedrooms": 4, "count": 18, "sizeSqft": 3800}],
|
|
},
|
|
"Alipore Azure Residences": {
|
|
"developer": "Meridian Urban Estates",
|
|
"micro_market": "Alipore",
|
|
"address": "Judges Court Road, Alipore, Kolkata",
|
|
"property_type": "apartment",
|
|
"location": {"city": "Kolkata", "district": "Alipore", "lat": 22.5288, "lng": 88.3309},
|
|
"price_bands": [{"unitType": "3BHK Signature", "minINR": 85000000, "maxINR": 125000000}],
|
|
"unit_mix": [{"bedrooms": 3, "count": 36, "sizeSqft": 2850}, {"bedrooms": 4, "count": 16, "sizeSqft": 4100}],
|
|
},
|
|
"Victoria Gardens Private Residences": {
|
|
"developer": "Heritage Habitat",
|
|
"micro_market": "Maidan",
|
|
"address": "Queen's Way Precinct, Kolkata",
|
|
"property_type": "penthouse",
|
|
"location": {"city": "Kolkata", "district": "Maidan", "lat": 22.5448, "lng": 88.3426},
|
|
"price_bands": [{"unitType": "Penthouse Duplex", "minINR": 220000000, "maxINR": 320000000}],
|
|
"unit_mix": [{"bedrooms": 5, "count": 8, "sizeSqft": 6200}],
|
|
},
|
|
"Salt Lake Atelier Homes": {
|
|
"developer": "Studio Habitat",
|
|
"micro_market": "Salt Lake Sector V",
|
|
"address": "EM Bypass Connector, Salt Lake, Kolkata",
|
|
"property_type": "apartment",
|
|
"location": {"city": "Kolkata", "district": "Salt Lake", "lat": 22.5797, "lng": 88.4353},
|
|
"price_bands": [{"unitType": "3.5BHK Garden Home", "minINR": 68000000, "maxINR": 98000000}],
|
|
"unit_mix": [{"bedrooms": 3, "count": 28, "sizeSqft": 2350}],
|
|
},
|
|
"Ballygunge Meridian": {
|
|
"developer": "Eastern Crest Realty",
|
|
"micro_market": "Ballygunge",
|
|
"address": "Ballygunge Circular Road, Kolkata",
|
|
"property_type": "apartment",
|
|
"location": {"city": "Kolkata", "district": "Ballygunge", "lat": 22.5276, "lng": 88.3651},
|
|
"price_bands": [{"unitType": "3BHK", "minINR": 42000000, "maxINR": 62000000}],
|
|
"unit_mix": [{"bedrooms": 3, "count": 54, "sizeSqft": 1780}],
|
|
},
|
|
}
|
|
|
|
PROJECTS.update(
|
|
{
|
|
"Emaar Palm Heights Dubai": {
|
|
"developer": "Emaar Properties",
|
|
"micro_market": "Dubai Hills Estate",
|
|
"address": "Dubai Hills Estate, Dubai, UAE",
|
|
"property_type": "penthouse",
|
|
"location": {"city": "Dubai", "district": "Dubai Hills", "lat": 25.1132, "lng": 55.2477},
|
|
"price_bands": [
|
|
{"unitType": "4BR Sky Penthouse", "minUSD": 3200000, "maxUSD": 5200000},
|
|
{"unitType": "3BR Residence", "minUSD": 1800000, "maxUSD": 2600000},
|
|
],
|
|
"unit_mix": [{"bedrooms": 4, "count": 16, "sizeSqft": 5200}, {"bedrooms": 3, "count": 42, "sizeSqft": 3100}],
|
|
},
|
|
"Sobha Hartland Estates": {
|
|
"developer": "Sobha Realty",
|
|
"micro_market": "Mohammed Bin Rashid City",
|
|
"address": "Sobha Hartland, MBR City, Dubai, UAE",
|
|
"property_type": "villa",
|
|
"location": {"city": "Dubai", "district": "MBR City", "lat": 25.1763, "lng": 55.3067},
|
|
"price_bands": [
|
|
{"unitType": "5BR Waterfront Villa", "minUSD": 4500000, "maxUSD": 7800000},
|
|
{"unitType": "4BR Garden Villa", "minUSD": 2900000, "maxUSD": 4200000},
|
|
],
|
|
"unit_mix": [{"bedrooms": 5, "count": 12, "sizeSqft": 8100}, {"bedrooms": 4, "count": 24, "sizeSqft": 5900}],
|
|
},
|
|
"Emaar Urban Oasis Gurugram": {
|
|
"developer": "Emaar India",
|
|
"micro_market": "Golf Course Extension",
|
|
"address": "Sector 62, Gurugram, India",
|
|
"property_type": "apartment",
|
|
"location": {"city": "Gurugram", "district": "Golf Course Extension", "lat": 28.4059, "lng": 77.0964},
|
|
"price_bands": [
|
|
{"unitType": "4BHK Signature", "minINR": 95000000, "maxINR": 155000000},
|
|
{"unitType": "Penthouse", "minINR": 180000000, "maxINR": 290000000},
|
|
],
|
|
"unit_mix": [{"bedrooms": 4, "count": 44, "sizeSqft": 4200}, {"bedrooms": 5, "count": 10, "sizeSqft": 6200}],
|
|
},
|
|
"Sobha Neopolis Presidential": {
|
|
"developer": "Sobha Limited",
|
|
"micro_market": "Panathur",
|
|
"address": "Panathur Main Road, Bengaluru, India",
|
|
"property_type": "apartment",
|
|
"location": {"city": "Bengaluru", "district": "Panathur", "lat": 12.9357, "lng": 77.7037},
|
|
"price_bands": [
|
|
{"unitType": "4BHK Presidential", "minINR": 85000000, "maxINR": 140000000},
|
|
{"unitType": "3BHK Luxury", "minINR": 42000000, "maxINR": 68000000},
|
|
],
|
|
"unit_mix": [{"bedrooms": 4, "count": 30, "sizeSqft": 3600}, {"bedrooms": 3, "count": 72, "sizeSqft": 2400}],
|
|
},
|
|
"Emaar Ocean Crown Abu Dhabi": {
|
|
"developer": "Emaar Properties",
|
|
"micro_market": "Saadiyat Island",
|
|
"address": "Saadiyat Cultural District, Abu Dhabi, UAE",
|
|
"property_type": "branded_residence",
|
|
"location": {"city": "Abu Dhabi", "district": "Saadiyat Island", "lat": 24.5442, "lng": 54.4332},
|
|
"price_bands": [
|
|
{"unitType": "Royal Beachfront Penthouse", "minUSD": 6200000, "maxUSD": 11000000},
|
|
{"unitType": "3BR Beach Residence", "minUSD": 2400000, "maxUSD": 3900000},
|
|
],
|
|
"unit_mix": [{"bedrooms": 5, "count": 6, "sizeSqft": 9200}, {"bedrooms": 3, "count": 28, "sizeSqft": 3400}],
|
|
},
|
|
}
|
|
)
|
|
|
|
PROPERTY_MEDIA_URLS = [
|
|
"https://images.unsplash.com/photo-1600585154340-be6161a56a0c",
|
|
"https://images.unsplash.com/photo-1600607687939-ce8a6c25118c",
|
|
"https://images.unsplash.com/photo-1600566753190-17f0baa2a6c3",
|
|
"https://images.unsplash.com/photo-1600585154526-990dced4db0d",
|
|
"https://images.unsplash.com/photo-1613490493576-7fde63acd811",
|
|
"https://images.unsplash.com/photo-1600607687644-aac4c3eac7f4",
|
|
"https://images.unsplash.com/photo-1600566752355-35792bedcfea",
|
|
"https://images.unsplash.com/photo-1600607687920-4e2a09cf159d",
|
|
]
|
|
|
|
|
|
def _media_url(seed_index: int, *, width: int, height: int) -> str:
|
|
base = PROPERTY_MEDIA_URLS[seed_index % len(PROPERTY_MEDIA_URLS)]
|
|
return f"{base}?auto=format&fit=crop&w={width}&h={height}&q=82"
|
|
|
|
|
|
def _project_media_assets(project_name: str, project: dict[str, Any], index: int) -> list[dict[str, Any]]:
|
|
slug = re.sub(r"[^a-z0-9]+", "-", project_name.lower()).strip("-")
|
|
location = project["location"]
|
|
return [
|
|
{
|
|
"key": f"{slug}:hero",
|
|
"media_type": "image",
|
|
"url": _media_url(index, width=1600, height=1000),
|
|
"thumbnail_url": _media_url(index, width=640, height=420),
|
|
"sort_order": 0,
|
|
"metadata": {
|
|
"seed_source": SEED_SOURCE,
|
|
"demo_asset_kind": "property_hero",
|
|
"project_name": project_name,
|
|
"developer": project["developer"],
|
|
"city": location["city"],
|
|
"district": location["district"],
|
|
},
|
|
},
|
|
{
|
|
"key": f"{slug}:floorplan",
|
|
"media_type": "floorplan",
|
|
"url": _media_url(index + 3, width=1600, height=1000),
|
|
"thumbnail_url": _media_url(index + 3, width=640, height=420),
|
|
"sort_order": 1,
|
|
"metadata": {
|
|
"seed_source": SEED_SOURCE,
|
|
"demo_asset_kind": "floor_plan_reference",
|
|
"project_name": project_name,
|
|
"unit_mix": project["unit_mix"],
|
|
},
|
|
},
|
|
]
|
|
|
|
|
|
def _phone_for(index: int, country: str) -> str:
|
|
if country == "UAE":
|
|
return f"+971-50-{7200000 + index:07d}"
|
|
return f"+91-98{30000000 + index:08d}"[:14]
|
|
|
|
|
|
def _expand_demo_clients(seed_clients: list[dict[str, Any]], target_count: int) -> list[dict[str, Any]]:
|
|
if len(seed_clients) >= target_count:
|
|
return seed_clients
|
|
|
|
first_names = [
|
|
"Aarav", "Ishaan", "Kabir", "Reyansh", "Vihaan", "Anika", "Aanya", "Kiara",
|
|
"Navya", "Rhea", "Omar", "Mariam", "Zayed", "Leila", "Farah", "Aditya",
|
|
"Naina", "Rohan", "Tara", "Samaira", "Armaan", "Dev", "Saanvi", "Ayesha",
|
|
]
|
|
last_names = [
|
|
"Mehta", "Kapoor", "Khanna", "Bhatia", "Sarin", "Al Maktoum", "Al Futtaim",
|
|
"Al Habtoor", "Nair", "Menon", "Reddy", "Pillai", "Chopra", "Raheja",
|
|
"Jindal", "Goenka", "Dalmia", "Merchant", "Saxena", "Bose",
|
|
]
|
|
buyer_types = ["hni_end_user", "nri_investor", "family_office", "founder_buyer", "investor"]
|
|
statuses = ["qualified", "site_visit_scheduled", "site_visited", "negotiation", "booking_initiated", "contacted"]
|
|
stages = ["qualified", "proposal", "site_visit", "negotiation", "booking"]
|
|
urgencies = ["medium", "high", "critical"]
|
|
project_names = list(PROJECTS.keys())
|
|
expanded = list(seed_clients)
|
|
|
|
for index in range(len(seed_clients), target_count):
|
|
first = first_names[index % len(first_names)]
|
|
last = last_names[(index * 3) % len(last_names)]
|
|
project_name = project_names[index % len(project_names)]
|
|
project = PROJECTS[project_name]
|
|
city = project["location"]["city"]
|
|
is_uae = city in {"Dubai", "Abu Dhabi"}
|
|
country = "UAE" if is_uae else "India"
|
|
buyer_type = buyer_types[index % len(buyer_types)]
|
|
urgency = urgencies[index % len(urgencies)]
|
|
high_value_usd = 1_150_000 + (index % 9) * 425_000
|
|
value = high_value_usd if is_uae else 82_000_000 + (index % 11) * 17_500_000
|
|
budget_label = f"${high_value_usd / 1_000_000:.1f}-${(high_value_usd + 950_000) / 1_000_000:.1f}M" if is_uae else f"{int(value / 10_000_000)}-{int(value / 10_000_000) + 4} Cr"
|
|
configuration = project["unit_mix"][0]["bedrooms"]
|
|
unit_label = "Royal penthouse stack" if value >= (4_000_000 if is_uae else 180_000_000) else "High-floor signature residence"
|
|
full_name = f"{first} {last}"
|
|
key = re.sub(r"[^a-z0-9]+", "-", full_name.lower()).strip("-") + f"-{index:02d}"
|
|
expanded.append(
|
|
{
|
|
"key": key,
|
|
"name": full_name,
|
|
"email": f"{key}@demo.desineuron.in",
|
|
"phone": _phone_for(index, country),
|
|
"buyer_type": buyer_type,
|
|
"city": city,
|
|
"nationality": "Emirati" if is_uae and index % 3 == 0 else "Indian",
|
|
"persona": [buyer_type, "tier_1_developer_pitch", "high_value_pipeline"],
|
|
"budget": budget_label,
|
|
"urgency": urgency,
|
|
"status": statuses[index % len(statuses)],
|
|
"project": project_name,
|
|
"configuration": f"{configuration}BR Signature Residence",
|
|
"unit": unit_label,
|
|
"budget_min": int(value * 0.88),
|
|
"budget_max": int(value * 1.22),
|
|
"stage": stages[index % len(stages)],
|
|
"value": value,
|
|
"probability": min(92, 48 + (index * 7) % 43),
|
|
"next_action": f"Prepare developer-grade commercial deck for {project_name} and schedule executive walkthrough.",
|
|
"scores": (
|
|
round(0.68 + ((index * 7) % 28) / 100, 2),
|
|
round(0.64 + ((index * 5) % 30) / 100, 2),
|
|
round(0.62 + ((index * 11) % 35) / 100, 2),
|
|
),
|
|
}
|
|
)
|
|
return expanded
|
|
|
|
|
|
DEMO_CLIENTS = _expand_demo_clients(DEMO_CLIENTS, DEMO_CLIENT_TARGET_COUNT)
|
|
|
|
|
|
async def seed(conn: asyncpg.Connection, tenant_id: str, operator_user_id: str | None, dry_run: bool = False) -> dict[str, int]:
|
|
now = datetime.now(timezone.utc)
|
|
owner_user_ref = operator_user_id or f"{SEED_SOURCE}:operator"
|
|
counts = {
|
|
"people": 0,
|
|
"leads": 0,
|
|
"projects": 0,
|
|
"properties": 0,
|
|
"property_media": 0,
|
|
"interests": 0,
|
|
"opportunities": 0,
|
|
"scores": 0,
|
|
"interactions": 0,
|
|
"edge_events": 0,
|
|
"reminders": 0,
|
|
"calendar_events": 0,
|
|
"insight_recommendations": 0,
|
|
"comms_threads": 0,
|
|
"comms_messages": 0,
|
|
"comms_call_logs": 0,
|
|
"import_batches": 0,
|
|
"import_proposals": 0,
|
|
}
|
|
if dry_run:
|
|
return counts
|
|
|
|
project_ids: dict[str, str] = {}
|
|
for project_index, (name, project) in enumerate(PROJECTS.items()):
|
|
project_id = _stable_uuid(tenant_id, f"project:{name}")
|
|
project_ids[name] = project_id
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO inventory_projects (
|
|
project_id, project_name, developer_name, city, micro_market, address,
|
|
total_units, project_status, location_json, amenities_json, metadata_json
|
|
) VALUES (
|
|
$1::uuid, $2, $3, $4, $5, $6, $7, 'active',
|
|
$8::jsonb, $9::jsonb, $10::jsonb
|
|
)
|
|
ON CONFLICT (project_name) DO UPDATE SET
|
|
developer_name = EXCLUDED.developer_name,
|
|
micro_market = EXCLUDED.micro_market,
|
|
address = EXCLUDED.address,
|
|
total_units = EXCLUDED.total_units,
|
|
project_status = EXCLUDED.project_status,
|
|
location_json = EXCLUDED.location_json,
|
|
amenities_json = EXCLUDED.amenities_json,
|
|
metadata_json = inventory_projects.metadata_json || EXCLUDED.metadata_json,
|
|
updated_at = NOW()
|
|
""",
|
|
project_id,
|
|
name,
|
|
project["developer"],
|
|
project["location"]["city"],
|
|
project["micro_market"],
|
|
project["address"],
|
|
sum(item["count"] for item in project["unit_mix"]),
|
|
_json(project["location"]),
|
|
_json(["concierge", "private club", "fitness studio", "visitor lounge", "ev charging"]),
|
|
_json({"seed_source": SEED_SOURCE, "ipad_demo": True}),
|
|
)
|
|
counts["projects"] += 1
|
|
|
|
property_id = _stable_uuid(tenant_id, f"inventory-property:{name}")
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO inventory_properties (
|
|
property_id, tenant_id, source_id, project_name, developer_name,
|
|
location, property_type, price_bands, unit_mix, amenities,
|
|
status, validation_state, ingested_at, created_at, updated_at
|
|
) VALUES (
|
|
$1::uuid, $2, $3, $4, $5, $6::jsonb, $7, $8::jsonb, $9::jsonb,
|
|
$10, 'active', $11::jsonb, NOW(), NOW(), NOW()
|
|
)
|
|
ON CONFLICT (property_id) DO UPDATE SET
|
|
project_name = EXCLUDED.project_name,
|
|
developer_name = EXCLUDED.developer_name,
|
|
location = EXCLUDED.location,
|
|
property_type = EXCLUDED.property_type,
|
|
price_bands = EXCLUDED.price_bands,
|
|
unit_mix = EXCLUDED.unit_mix,
|
|
amenities = EXCLUDED.amenities,
|
|
status = 'active',
|
|
validation_state = EXCLUDED.validation_state,
|
|
updated_at = NOW()
|
|
""",
|
|
property_id,
|
|
tenant_id,
|
|
f"{SEED_SOURCE}:{name}",
|
|
name,
|
|
project["developer"],
|
|
_json(project["location"]),
|
|
project["property_type"],
|
|
_json(project["price_bands"]),
|
|
_json(project["unit_mix"]),
|
|
["concierge", "clubhouse", "security", "ev charging", "landscaped deck"],
|
|
_json({"seed_source": SEED_SOURCE, "validated_for_ipad_demo": True}),
|
|
)
|
|
counts["properties"] += 1
|
|
|
|
for media in _project_media_assets(name, project, project_index):
|
|
media_asset_id = _stable_uuid(tenant_id, f"inventory-media:{media['key']}")
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO inventory_media_assets (
|
|
media_asset_id, property_id, tenant_id, media_type, url,
|
|
thumbnail_url, sort_order, metadata, uploaded_by, created_at
|
|
) VALUES (
|
|
$1::uuid, $2::uuid, $3, $4, $5, $6, $7, $8::jsonb, $9, NOW()
|
|
)
|
|
ON CONFLICT (media_asset_id) DO UPDATE SET
|
|
media_type = EXCLUDED.media_type,
|
|
url = EXCLUDED.url,
|
|
thumbnail_url = EXCLUDED.thumbnail_url,
|
|
sort_order = EXCLUDED.sort_order,
|
|
metadata = inventory_media_assets.metadata || EXCLUDED.metadata,
|
|
uploaded_by = EXCLUDED.uploaded_by
|
|
""",
|
|
media_asset_id,
|
|
property_id,
|
|
tenant_id,
|
|
media["media_type"],
|
|
media["url"],
|
|
media["thumbnail_url"],
|
|
media["sort_order"],
|
|
_json(media["metadata"]),
|
|
owner_user_ref,
|
|
)
|
|
counts["property_media"] += 1
|
|
|
|
for index, client in enumerate(DEMO_CLIENTS):
|
|
person_id = _stable_uuid(tenant_id, f"person:{client['key']}")
|
|
lead_id = _stable_uuid(tenant_id, f"lead:{client['key']}")
|
|
opportunity_id = _stable_uuid(tenant_id, f"opportunity:{client['key']}")
|
|
project_id = project_ids[client["project"]]
|
|
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO crm_people (
|
|
person_id, tenant_id, full_name, primary_email, primary_phone, city,
|
|
nationality, buyer_type, persona_labels, source_confidence,
|
|
metadata_json, created_at, updated_at
|
|
) VALUES (
|
|
$1::uuid, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, 0.98,
|
|
$10::jsonb, NOW() - ($11::int * INTERVAL '2 days'), NOW()
|
|
)
|
|
ON CONFLICT (person_id) DO UPDATE SET
|
|
full_name = EXCLUDED.full_name,
|
|
primary_email = EXCLUDED.primary_email,
|
|
primary_phone = EXCLUDED.primary_phone,
|
|
city = EXCLUDED.city,
|
|
nationality = EXCLUDED.nationality,
|
|
buyer_type = EXCLUDED.buyer_type,
|
|
persona_labels = EXCLUDED.persona_labels,
|
|
source_confidence = EXCLUDED.source_confidence,
|
|
metadata_json = crm_people.metadata_json || EXCLUDED.metadata_json,
|
|
updated_at = NOW()
|
|
""",
|
|
person_id,
|
|
tenant_id,
|
|
client["name"],
|
|
client["email"],
|
|
client["phone"],
|
|
client["city"],
|
|
client["nationality"],
|
|
client["buyer_type"],
|
|
_json(client["persona"]),
|
|
_json({"seed_source": SEED_SOURCE, "investor_demo": True, "source_note": "iPad production readiness seed"}),
|
|
index,
|
|
)
|
|
counts["people"] += 1
|
|
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO crm_leads (
|
|
lead_id, tenant_id, person_id, source_system, status, budget_band,
|
|
urgency, financing_posture, timeline_to_decision, objections,
|
|
motivations, assigned_user_id, metadata_json, created_at, updated_at
|
|
) VALUES (
|
|
$1::uuid, $2, $3::uuid, 'ipad_investor_demo',
|
|
$4::crm_lead_status, $5, $6, $7, $8, $9::jsonb, $10::jsonb,
|
|
$11::uuid, $12::jsonb, NOW() - ($13::int * INTERVAL '2 days'), NOW()
|
|
)
|
|
ON CONFLICT (lead_id) DO UPDATE SET
|
|
status = EXCLUDED.status,
|
|
budget_band = EXCLUDED.budget_band,
|
|
urgency = EXCLUDED.urgency,
|
|
financing_posture = EXCLUDED.financing_posture,
|
|
timeline_to_decision = EXCLUDED.timeline_to_decision,
|
|
objections = EXCLUDED.objections,
|
|
motivations = EXCLUDED.motivations,
|
|
assigned_user_id = EXCLUDED.assigned_user_id,
|
|
metadata_json = crm_leads.metadata_json || EXCLUDED.metadata_json,
|
|
updated_at = NOW()
|
|
""",
|
|
lead_id,
|
|
tenant_id,
|
|
person_id,
|
|
client["status"],
|
|
client["budget"],
|
|
client["urgency"],
|
|
"cash_and_structured_payment" if client["budget_min"] >= 100000000 else "bank_loan_preapproved",
|
|
"30_days" if client["urgency"] in {"high", "critical"} else "60_to_90_days",
|
|
_json(["needs_family_alignment"] if client["urgency"] != "critical" else ["price_protection", "privacy"]),
|
|
_json(["upgrade_primary_home", "wealth_preservation", "status_address"]),
|
|
operator_user_id,
|
|
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
|
|
index,
|
|
)
|
|
counts["leads"] += 1
|
|
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO crm_property_interests (
|
|
interest_id, tenant_id, person_id, lead_id, project_id, project_name,
|
|
unit_preference, configuration, budget_min, budget_max, priority, notes, created_at
|
|
) VALUES (
|
|
$1::uuid, $2, $3::uuid, $4::uuid, $5::uuid, $6, $7, $8, $9, $10, 1, $11, NOW()
|
|
)
|
|
ON CONFLICT (interest_id) DO UPDATE SET
|
|
project_name = EXCLUDED.project_name,
|
|
unit_preference = EXCLUDED.unit_preference,
|
|
configuration = EXCLUDED.configuration,
|
|
budget_min = EXCLUDED.budget_min,
|
|
budget_max = EXCLUDED.budget_max,
|
|
notes = EXCLUDED.notes
|
|
""",
|
|
_stable_uuid(tenant_id, f"interest:{client['key']}"),
|
|
tenant_id,
|
|
person_id,
|
|
lead_id,
|
|
project_id,
|
|
client["project"],
|
|
client["unit"],
|
|
client["configuration"],
|
|
client["budget_min"],
|
|
client["budget_max"],
|
|
f"Seeded for iPad investor demo by {SEED_SOURCE}.",
|
|
)
|
|
counts["interests"] += 1
|
|
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO crm_opportunities (
|
|
opportunity_id, tenant_id, lead_id, project_id, stage, value,
|
|
probability, expected_close_date, next_action, notes, metadata_json,
|
|
created_at, updated_at
|
|
) VALUES (
|
|
$1::uuid, $2, $3::uuid, $4::uuid, $5::crm_opportunity_stage, $6,
|
|
$7, $8::date, $9, $10, $11::jsonb, NOW() - ($12::int * INTERVAL '1 day'), NOW()
|
|
)
|
|
ON CONFLICT (opportunity_id) DO UPDATE SET
|
|
project_id = EXCLUDED.project_id,
|
|
stage = EXCLUDED.stage,
|
|
value = EXCLUDED.value,
|
|
probability = EXCLUDED.probability,
|
|
expected_close_date = EXCLUDED.expected_close_date,
|
|
next_action = EXCLUDED.next_action,
|
|
notes = EXCLUDED.notes,
|
|
metadata_json = crm_opportunities.metadata_json || EXCLUDED.metadata_json,
|
|
updated_at = NOW()
|
|
""",
|
|
opportunity_id,
|
|
tenant_id,
|
|
lead_id,
|
|
project_id,
|
|
client["stage"],
|
|
client["value"],
|
|
client["probability"],
|
|
(now + timedelta(days=21 + index * 4)).date().isoformat(),
|
|
client["next_action"],
|
|
"Investor-demo opportunity with realistic project, value, and next-step context.",
|
|
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
|
|
index,
|
|
)
|
|
counts["opportunities"] += 1
|
|
|
|
for score_type, value in zip(("intent_score", "engagement_score", "urgency_score"), client["scores"]):
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO intel_qd_scores (
|
|
qd_id, tenant_id, person_id, score_type, current_value,
|
|
computed_at, evidence_refs_json, reasoning, metadata_json
|
|
) VALUES (
|
|
$1::uuid, $2, $3::uuid, $4, $5, NOW(), $6::jsonb, $7, $8::jsonb
|
|
)
|
|
ON CONFLICT (person_id, score_type) DO UPDATE SET
|
|
tenant_id = EXCLUDED.tenant_id,
|
|
current_value = EXCLUDED.current_value,
|
|
computed_at = NOW(),
|
|
evidence_refs_json = EXCLUDED.evidence_refs_json,
|
|
reasoning = EXCLUDED.reasoning,
|
|
metadata_json = intel_qd_scores.metadata_json || EXCLUDED.metadata_json
|
|
""",
|
|
_stable_uuid(tenant_id, f"qd:{client['key']}:{score_type}"),
|
|
tenant_id,
|
|
person_id,
|
|
score_type,
|
|
value,
|
|
_json([f"seed:{client['key']}:interaction"]),
|
|
f"{client['name']} shows {score_type.replace('_', ' ')} from recent budget, project, and follow-up signals.",
|
|
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
|
|
)
|
|
counts["scores"] += 1
|
|
|
|
interaction_templates = [
|
|
("whatsapp", "message", now - timedelta(hours=6 + index), "Confirmed budget band and asked for a project comparison deck."),
|
|
("phone", "call", now - timedelta(days=1, hours=index), "Discussed decision timeline, family alignment, and next visit window."),
|
|
("site_visit", "visit", now - timedelta(days=3 + index), f"Reviewed {client['project']} and shortlisted {client['configuration']}."),
|
|
]
|
|
for event_index, (channel, interaction_type, happened_at, summary) in enumerate(interaction_templates):
|
|
interaction_id = _stable_uuid(tenant_id, f"interaction:{client['key']}:{event_index}")
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO intel_interactions (
|
|
interaction_id, tenant_id, person_id, lead_id, channel, interaction_type,
|
|
happened_at, summary, source_ref, metadata_json, created_at
|
|
) VALUES (
|
|
$1::uuid, $2, $3::uuid, $4::uuid, $5::intel_channel, $6,
|
|
$7, $8, $9, $10::jsonb, NOW()
|
|
)
|
|
ON CONFLICT (interaction_id) DO UPDATE SET
|
|
happened_at = EXCLUDED.happened_at,
|
|
summary = EXCLUDED.summary,
|
|
metadata_json = intel_interactions.metadata_json || EXCLUDED.metadata_json
|
|
""",
|
|
interaction_id,
|
|
tenant_id,
|
|
person_id,
|
|
lead_id,
|
|
channel,
|
|
interaction_type,
|
|
happened_at,
|
|
summary,
|
|
f"{SEED_SOURCE}:{client['key']}:{event_index}",
|
|
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
|
|
)
|
|
counts["interactions"] += 1
|
|
|
|
edge_event_id = _stable_uuid(tenant_id, f"edge-event:{client['key']}")
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO edge_communication_events (
|
|
event_id, tenant_id, lead_id, channel, direction, provider,
|
|
capture_mode, consent_state, timestamp, duration_seconds,
|
|
summary, raw_reference, provider_metadata, created_at, updated_at
|
|
) VALUES (
|
|
$1::uuid, $2, $3, 'whatsapp_message', 'inbound', 'operator_seed',
|
|
'operator_note', 'granted', $4, NULL, $5, $6, $7::jsonb, NOW(), NOW()
|
|
)
|
|
ON CONFLICT (event_id) DO UPDATE SET
|
|
timestamp = EXCLUDED.timestamp,
|
|
summary = EXCLUDED.summary,
|
|
provider_metadata = EXCLUDED.provider_metadata,
|
|
updated_at = NOW()
|
|
""",
|
|
edge_event_id,
|
|
tenant_id,
|
|
lead_id,
|
|
now - timedelta(minutes=25 + index * 12),
|
|
f"{client['name']} asked the operator to proceed with the next step: {client['next_action']}",
|
|
f"{SEED_SOURCE}:{client['key']}",
|
|
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
|
|
)
|
|
counts["edge_events"] += 1
|
|
|
|
reminder_id = _stable_uuid(tenant_id, f"reminder:{client['key']}")
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO intel_reminders (
|
|
reminder_id, tenant_id, person_id, lead_id, opportunity_id, reminder_type,
|
|
title, notes, due_at, status, assigned_to, created_by_type, priority,
|
|
metadata_json, created_at
|
|
) VALUES (
|
|
$1::uuid, $2, $3::uuid, $4::uuid, $5::uuid, 'follow_up',
|
|
$6, $7, $8, 'pending', $9::uuid, 'human', $10, $11::jsonb, NOW()
|
|
)
|
|
ON CONFLICT (reminder_id) DO UPDATE SET
|
|
title = EXCLUDED.title,
|
|
notes = EXCLUDED.notes,
|
|
due_at = EXCLUDED.due_at,
|
|
status = 'pending',
|
|
assigned_to = EXCLUDED.assigned_to,
|
|
priority = EXCLUDED.priority,
|
|
metadata_json = intel_reminders.metadata_json || EXCLUDED.metadata_json
|
|
""",
|
|
reminder_id,
|
|
tenant_id,
|
|
person_id,
|
|
lead_id,
|
|
opportunity_id,
|
|
f"Follow up with {client['name']} on {client['project']}",
|
|
client["next_action"],
|
|
now + timedelta(hours=3 + index * 4),
|
|
operator_user_id,
|
|
"urgent" if client["urgency"] == "critical" else ("high" if client["urgency"] == "high" else "normal"),
|
|
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
|
|
)
|
|
counts["reminders"] += 1
|
|
|
|
calendar_id = _stable_uuid(tenant_id, f"calendar:{client['key']}")
|
|
start_at = now + timedelta(days=1 + (index % 3), hours=2 + index)
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO user_calendar_events (
|
|
calendar_event_id, tenant_id, owner_user_id, lead_id, source_event_id,
|
|
title, description, start_at, end_at, all_day, status,
|
|
reminder_minutes, created_by, is_nemoclaw_confirmed, location,
|
|
metadata, created_at, updated_at
|
|
) VALUES (
|
|
$1::uuid, $2, $3, $4, $5::uuid, $6, $7, $8, $9, FALSE, 'confirmed',
|
|
ARRAY[15, 60], 'user', TRUE, $10, $11::jsonb, NOW(), NOW()
|
|
)
|
|
ON CONFLICT (calendar_event_id) DO UPDATE SET
|
|
title = EXCLUDED.title,
|
|
description = EXCLUDED.description,
|
|
start_at = EXCLUDED.start_at,
|
|
end_at = EXCLUDED.end_at,
|
|
status = 'confirmed',
|
|
reminder_minutes = EXCLUDED.reminder_minutes,
|
|
location = EXCLUDED.location,
|
|
metadata = EXCLUDED.metadata,
|
|
updated_at = NOW()
|
|
""",
|
|
calendar_id,
|
|
tenant_id,
|
|
owner_user_ref,
|
|
lead_id,
|
|
edge_event_id,
|
|
f"{client['name']} - {client['configuration']} review",
|
|
client["next_action"],
|
|
start_at,
|
|
start_at + timedelta(minutes=45),
|
|
client["project"],
|
|
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
|
|
)
|
|
counts["calendar_events"] += 1
|
|
|
|
recommendation_id = _stable_uuid(tenant_id, f"insight:{client['key']}")
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO insight_recommendations (
|
|
recommendation_id, tenant_id, lead_id, source_event_id,
|
|
recommendation_type, summary, suggested_action, target_system,
|
|
status, confidence, created_at, updated_at
|
|
) VALUES (
|
|
$1::uuid, $2, $3, $4::uuid, $5, $6, $7, $8,
|
|
'pending', $9, NOW() - ($10::int * INTERVAL '8 minutes'), NOW()
|
|
)
|
|
ON CONFLICT (recommendation_id) DO UPDATE SET
|
|
source_event_id = EXCLUDED.source_event_id,
|
|
recommendation_type = EXCLUDED.recommendation_type,
|
|
summary = EXCLUDED.summary,
|
|
suggested_action = EXCLUDED.suggested_action,
|
|
target_system = EXCLUDED.target_system,
|
|
status = 'pending',
|
|
confidence = EXCLUDED.confidence,
|
|
updated_at = NOW()
|
|
""",
|
|
recommendation_id,
|
|
tenant_id,
|
|
lead_id,
|
|
edge_event_id,
|
|
"schedule_meeting" if client["urgency"] in {"high", "critical"} else "send_property_info",
|
|
f"{client['name']} is showing strong buying intent for {client['project']}.",
|
|
client["next_action"],
|
|
"calendar" if client["urgency"] in {"high", "critical"} else "whatsapp",
|
|
min(0.98, float(client["scores"][0]) + 0.04),
|
|
index,
|
|
)
|
|
counts["insight_recommendations"] += 1
|
|
|
|
thread_id = _stable_uuid(tenant_id, f"comms-thread:{client['key']}")
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO comms_threads (
|
|
thread_id, provider, external_thread_id, person_id, phone_e164,
|
|
display_name, channel, status, assigned_user_id, last_message_at,
|
|
unread_count, metadata_json, created_at, updated_at
|
|
) VALUES (
|
|
$1::uuid, 'waha', $2, $3::uuid, $4, $5, 'whatsapp',
|
|
'open', $6::uuid, $7, 1, $8::jsonb, NOW() - INTERVAL '3 days', NOW()
|
|
)
|
|
ON CONFLICT (thread_id) DO UPDATE SET
|
|
person_id = EXCLUDED.person_id,
|
|
phone_e164 = EXCLUDED.phone_e164,
|
|
display_name = EXCLUDED.display_name,
|
|
assigned_user_id = EXCLUDED.assigned_user_id,
|
|
last_message_at = EXCLUDED.last_message_at,
|
|
unread_count = EXCLUDED.unread_count,
|
|
metadata_json = comms_threads.metadata_json || EXCLUDED.metadata_json,
|
|
updated_at = NOW()
|
|
""",
|
|
thread_id,
|
|
f"{SEED_SOURCE}:{client['key']}",
|
|
person_id,
|
|
client["phone"],
|
|
client["name"],
|
|
operator_user_id,
|
|
now - timedelta(minutes=18 + index * 9),
|
|
_json({"seed_source": SEED_SOURCE, "investor_demo": True, "project": client["project"]}),
|
|
)
|
|
counts["comms_threads"] += 1
|
|
|
|
message_bodies = [
|
|
("inbound", f"Can you send the latest floor stack and payment plan for {client['project']}?"),
|
|
("outbound", f"Sharing the executive deck now. I also reserved a walkthrough slot for {client['configuration']}."),
|
|
("inbound", f"Please proceed. Budget is aligned around {client['budget']} if the view and handover schedule work."),
|
|
]
|
|
for message_index, (direction, body) in enumerate(message_bodies):
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO comms_messages (
|
|
message_id, thread_id, provider, external_message_id, direction,
|
|
message_type, body, delivery_status, sent_at, delivered_at,
|
|
raw_payload, created_at
|
|
) VALUES (
|
|
$1::uuid, $2::uuid, 'waha', $3, $4, 'text', $5,
|
|
'delivered', $6, $6, $7::jsonb, NOW()
|
|
)
|
|
ON CONFLICT (message_id) DO UPDATE SET
|
|
body = EXCLUDED.body,
|
|
delivery_status = EXCLUDED.delivery_status,
|
|
sent_at = EXCLUDED.sent_at,
|
|
delivered_at = EXCLUDED.delivered_at,
|
|
raw_payload = EXCLUDED.raw_payload
|
|
""",
|
|
_stable_uuid(tenant_id, f"comms-message:{client['key']}:{message_index}"),
|
|
thread_id,
|
|
f"{SEED_SOURCE}:{client['key']}:{message_index}",
|
|
direction,
|
|
body,
|
|
now - timedelta(minutes=46 - message_index * 14 + index * 3),
|
|
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
|
|
)
|
|
counts["comms_messages"] += 1
|
|
|
|
call_id = _stable_uuid(tenant_id, f"comms-call:{client['key']}")
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO comms_call_logs (
|
|
call_id, thread_id, person_id, provider, external_call_id,
|
|
phone_e164, direction, status, started_at, ended_at,
|
|
duration_seconds, recording_url, transcript_text, raw_payload,
|
|
created_at
|
|
) VALUES (
|
|
$1::uuid, $2::uuid, $3::uuid, 'waha', $4, $5, 'outbound',
|
|
'completed', $6, $7, $8, $9, $10, $11::jsonb, NOW()
|
|
)
|
|
ON CONFLICT (call_id) DO UPDATE SET
|
|
thread_id = EXCLUDED.thread_id,
|
|
person_id = EXCLUDED.person_id,
|
|
status = EXCLUDED.status,
|
|
started_at = EXCLUDED.started_at,
|
|
ended_at = EXCLUDED.ended_at,
|
|
duration_seconds = EXCLUDED.duration_seconds,
|
|
recording_url = EXCLUDED.recording_url,
|
|
transcript_text = EXCLUDED.transcript_text,
|
|
raw_payload = EXCLUDED.raw_payload
|
|
""",
|
|
call_id,
|
|
thread_id,
|
|
person_id,
|
|
f"{SEED_SOURCE}:call:{client['key']}",
|
|
client["phone"],
|
|
now - timedelta(hours=2 + index),
|
|
now - timedelta(hours=2 + index) + timedelta(minutes=7, seconds=30),
|
|
450,
|
|
f"s3://velocity-demo-media/{SEED_SOURCE}/calls/{client['key']}.m4a",
|
|
f"Discussed {client['project']}, {client['configuration']}, budget {client['budget']}, and next action: {client['next_action']}",
|
|
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
|
|
)
|
|
counts["comms_call_logs"] += 1
|
|
|
|
batch_id = _stable_uuid(tenant_id, "workflow-import-batch:investor-demo")
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO workflow_import_batches (
|
|
batch_id, tenant_id, source_system, uploaded_filename, mime_type, storage_ref,
|
|
row_count, mapped_count, unresolved_count, canonical_count, uploaded_by,
|
|
lifecycle, mapping_manifest, errors_json, metadata_json, created_at, updated_at
|
|
) VALUES (
|
|
$1::uuid, $2, 'sales_ops_csv', 'investor_demo_priority_clients.csv', 'text/csv',
|
|
$3, 6, 5, 1, 0, $4::uuid, 'proposed',
|
|
$5::jsonb, '[]'::jsonb, $6::jsonb, NOW() - INTERVAL '2 hours', NOW()
|
|
)
|
|
ON CONFLICT (batch_id) DO UPDATE SET
|
|
row_count = EXCLUDED.row_count,
|
|
mapped_count = EXCLUDED.mapped_count,
|
|
unresolved_count = EXCLUDED.unresolved_count,
|
|
lifecycle = EXCLUDED.lifecycle,
|
|
mapping_manifest = EXCLUDED.mapping_manifest,
|
|
metadata_json = workflow_import_batches.metadata_json || EXCLUDED.metadata_json,
|
|
updated_at = NOW()
|
|
""",
|
|
batch_id,
|
|
tenant_id,
|
|
f"s3://velocity-demo/{SEED_SOURCE}/investor_demo_priority_clients.csv",
|
|
operator_user_id,
|
|
_json({"mapped": {"Name": "full_name", "Phone": "primary_phone", "Budget": "budget_band"}}),
|
|
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
|
|
)
|
|
counts["import_batches"] += 1
|
|
|
|
for row_number, client in enumerate(DEMO_CLIENTS[:3], start=1):
|
|
action_id = _stable_uuid(tenant_id, f"workflow-import-proposal:{client['key']}")
|
|
payload = {
|
|
"batch_id": batch_id,
|
|
"row_number": row_number,
|
|
"canonical_payload": {
|
|
"full_name": client["name"],
|
|
"primary_phone": client["phone"],
|
|
"buyer_type": client["buyer_type"],
|
|
"budget_band": client["budget"],
|
|
"project_name": client["project"],
|
|
},
|
|
"raw_row": {
|
|
"Name": client["name"],
|
|
"Phone": client["phone"],
|
|
"Budget": client["budget"],
|
|
"Project": client["project"],
|
|
},
|
|
"unresolved_fields": [] if row_number < 3 else ["preferred_visit_time"],
|
|
"missing_required": [],
|
|
"confidence": 0.92 - (row_number * 0.04),
|
|
"review_required": True,
|
|
}
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO workflow_actions (
|
|
action_id, tenant_id, action_type, target_domain, target_entity_ref,
|
|
proposal_payload, reasoning_summary, evidence_refs, confidence,
|
|
status, approval_required, created_by_agent, created_at, updated_at
|
|
) VALUES (
|
|
$1::uuid, $2, 'import_proposal', 'crm', $3,
|
|
$4::jsonb, $5, $6::jsonb, $7, 'pending', TRUE,
|
|
'velocity_ipad_seed', NOW() - INTERVAL '90 minutes', NOW()
|
|
)
|
|
ON CONFLICT (action_id) DO UPDATE SET
|
|
proposal_payload = EXCLUDED.proposal_payload,
|
|
reasoning_summary = EXCLUDED.reasoning_summary,
|
|
evidence_refs = EXCLUDED.evidence_refs,
|
|
confidence = EXCLUDED.confidence,
|
|
status = 'pending',
|
|
approval_required = TRUE,
|
|
updated_at = NOW()
|
|
""",
|
|
action_id,
|
|
tenant_id,
|
|
client["email"],
|
|
_json(payload),
|
|
f"Mapped {client['name']} from realistic investor-demo CRM import.",
|
|
_json([f"batch:{batch_id}", f"seed_source:{SEED_SOURCE}"]),
|
|
payload["confidence"],
|
|
)
|
|
counts["import_proposals"] += 1
|
|
|
|
return counts
|
|
|
|
|
|
async def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--operator-email", default=os.getenv("VELOCITY_DEMO_OPERATOR_EMAIL", DEFAULT_OPERATOR_EMAIL))
|
|
parser.add_argument("--tenant-id", default=os.getenv("VELOCITY_DEMO_TENANT_ID"))
|
|
parser.add_argument("--dry-run", action="store_true")
|
|
args = parser.parse_args()
|
|
|
|
_load_env()
|
|
if asyncpg is None:
|
|
raise RuntimeError(
|
|
"asyncpg is not installed. Install backend requirements first: "
|
|
"python3 -m pip install -r backend/requirements.txt"
|
|
)
|
|
if args.dry_run:
|
|
try:
|
|
db_kwargs = _db_kwargs()
|
|
except RuntimeError as exc:
|
|
print(
|
|
json.dumps(
|
|
{
|
|
"status": "dry_run_without_db",
|
|
"seed_source": SEED_SOURCE,
|
|
"operator_email": args.operator_email,
|
|
"database_note": str(exc),
|
|
"expected_counts": _expected_counts(),
|
|
},
|
|
indent=2,
|
|
)
|
|
)
|
|
return
|
|
else:
|
|
db_kwargs = _db_kwargs()
|
|
conn = await asyncpg.connect(**db_kwargs)
|
|
try:
|
|
tenant_id, operator_user_id = await _resolve_operator(conn, args.operator_email, args.tenant_id)
|
|
counts = _expected_counts() if args.dry_run else await seed(conn, tenant_id, operator_user_id)
|
|
print(
|
|
json.dumps(
|
|
{
|
|
"status": "dry_run" if args.dry_run else "seeded",
|
|
"seed_source": SEED_SOURCE,
|
|
"tenant_id": tenant_id,
|
|
"operator_email": args.operator_email,
|
|
"operator_user_id": operator_user_id,
|
|
"counts": counts,
|
|
},
|
|
indent=2,
|
|
)
|
|
)
|
|
finally:
|
|
await conn.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(main())
|