Files
Project_Velocity/backend/scripts/seed_ipad_investor_demo.py

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())