Initial commit: Velocity-OS migration

This commit is contained in:
2026-05-01 12:32:19 +05:30
commit 407af828d4
283 changed files with 207782 additions and 0 deletions

1
core/db/db/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""backend.db package"""

58
core/db/db/pool.py Normal file
View File

@@ -0,0 +1,58 @@
"""
backend/db/pool.py — asyncpg Connection Pool
Initialises a PostgreSQL connection pool from environment variables.
All credentials are sourced from the environment only — never hardcoded.
Environment variables required:
VELOCITY_DB_HOST PostgreSQL host (default: localhost)
VELOCITY_DB_PORT PostgreSQL port (default: 5432)
VELOCITY_DB_NAME Database name
VELOCITY_DB_USER Database user
VELOCITY_DB_PASSWORD Database password (injected from AWS SSM at service start)
"""
from __future__ import annotations
import os
from contextlib import asynccontextmanager
from typing import AsyncGenerator
import asyncpg
from fastapi import Request
_pool: asyncpg.Pool | None = None
async def create_pool() -> asyncpg.Pool:
"""Creates and returns the application-wide asyncpg connection pool."""
global _pool
_pool = await asyncpg.create_pool(
host=os.environ.get("VELOCITY_DB_HOST", "localhost"),
port=int(os.environ.get("VELOCITY_DB_PORT", "5432")),
database=os.environ["VELOCITY_DB_NAME"],
user=os.environ["VELOCITY_DB_USER"],
password=os.environ["VELOCITY_DB_PASSWORD"],
min_size=2,
max_size=10,
command_timeout=30,
# Set app_name for easier identification in pg_stat_activity
server_settings={"application_name": "velocity-backend"},
)
return _pool
async def close_pool() -> None:
"""Closes the connection pool on application shutdown."""
global _pool
if _pool:
await _pool.close()
_pool = None
def get_pool(request: Request) -> asyncpg.Pool:
"""FastAPI dependency: returns the pool stored in app.state."""
pool: asyncpg.Pool = request.app.state.db_pool
if pool is None:
raise RuntimeError("Database pool is not initialised.")
return pool

181
core/db/db/schema.sql Normal file
View File

@@ -0,0 +1,181 @@
-- backend/db/schema.sql - Velocity PostgreSQL schema
-- Omnichannel Intelligence Graph for The Sentinel.
--
-- Run via:
-- psql -U velocity_user -d velocity_db -f schema.sql
--
-- Or via Alembic (preferred - see backend/alembic/).
-- Enable UUID generation
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- ────────────────────────────────────────────────────────────────────────────
-- ENUM TYPES
-- ────────────────────────────────────────────────────────────────────────────
DO $$ BEGIN
CREATE TYPE role_enum AS ENUM (
'ADMIN',
'SALES_DIRECTOR',
'SENIOR_BROKER',
'JUNIOR_BROKER'
);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE source_enum AS ENUM ('whatsapp', 'website', 'walkin');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE lead_status_enum AS ENUM ('new', 'engaged', 'qualified', 'hot', 'closed');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE qualification_enum AS ENUM ('whale', 'potential', 'tire_kicker');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE log_event_enum AS ENUM (
'CALL_LOGGED',
'ASSET_VIEWED',
'SENTIMENT_SPIKE',
'QD_UPDATED',
'LEAD_TAGGED',
'WS_ASSET_OPENED'
);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
-- ────────────────────────────────────────────────────────────────────────────
-- TABLE: users_and_roles (Manual RBAC backbone)
-- ────────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS users_and_roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role role_enum NOT NULL DEFAULT 'JUNIOR_BROKER',
tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity',
full_name TEXT,
avatar_url TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
last_login TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Index for login lookups
CREATE INDEX IF NOT EXISTS idx_users_email ON users_and_roles (email);
CREATE INDEX IF NOT EXISTS idx_users_tenant_active ON users_and_roles (tenant_id, is_active);
-- ────────────────────────────────────────────────────────────────────────────
-- TABLE: leads_intelligence (CRM core with QD scoring)
-- ────────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS leads_intelligence (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
phone TEXT,
email TEXT,
source source_enum NOT NULL,
status lead_status_enum NOT NULL DEFAULT 'new',
qualification qualification_enum,
budget TEXT,
interest TEXT,
-- Quantum Dynamics Score: 1-100, updated in real-time by NemoClaw
quantum_dynamics_score INTEGER CHECK (quantum_dynamics_score BETWEEN 1 AND 100),
-- Polymorphic CRM intelligence tags e.g. ['HNI', 'NRI', 'Hot Lead']
tags TEXT[] NOT NULL DEFAULT '{}',
assigned_to UUID REFERENCES users_and_roles(id) ON DELETE SET NULL,
last_message TEXT,
last_active TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Auto-update updated_at on every modification
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_leads_updated_at ON leads_intelligence;
CREATE TRIGGER trg_leads_updated_at
BEFORE UPDATE ON leads_intelligence
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE INDEX IF NOT EXISTS idx_leads_status ON leads_intelligence (status);
CREATE INDEX IF NOT EXISTS idx_leads_assigned ON leads_intelligence (assigned_to);
-- GIN index for efficient tag array queries (e.g. WHERE 'HNI' = ANY(tags))
CREATE INDEX IF NOT EXISTS idx_leads_tags ON leads_intelligence USING GIN (tags);
-- ────────────────────────────────────────────────────────────────────────────
-- TABLE: velocity_vault_assets (File Tracking Engine)
-- ────────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS velocity_vault_assets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
asset_name TEXT NOT NULL,
asset_type TEXT NOT NULL, -- 'pdf', 'image', 'video'
storage_path TEXT NOT NULL, -- relative to /opt/dlami/nvme/assets/
-- Unique cryptographic string for every share instance
tracking_hash VARCHAR(64) UNIQUE NOT NULL,
lead_id UUID REFERENCES leads_intelligence(id) ON DELETE CASCADE,
created_by UUID REFERENCES users_and_roles(id),
-- Array of open timestamps; one entry appended per distinct open event
opened_at TIMESTAMPTZ[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_vault_hash ON velocity_vault_assets (tracking_hash);
CREATE INDEX IF NOT EXISTS idx_vault_lead ON velocity_vault_assets (lead_id);
-- ────────────────────────────────────────────────────────────────────────────
-- TABLE: omnichannel_logs (Polymorphic event ingestion + sentimental history)
-- ────────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS omnichannel_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_type log_event_enum NOT NULL,
lead_id UUID REFERENCES leads_intelligence(id) ON DELETE CASCADE,
-- JSONB payload — schema varies by event_type:
-- SENTIMENT_SPIKE: {blend_shapes, qd_before, qd_after}
-- WS_ASSET_OPENED: {ip, user_agent, tracking_hash}
-- QD_UPDATED: {qd_score, reasoning, confidence}
-- LEAD_TAGGED: {tags_added, tags_removed}
payload JSONB NOT NULL DEFAULT '{}',
-- For MediaPipe-correlated entries: exact ms offset in the stimulus video
video_timestamp_ms BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Composite index supporting time-series sentiment history queries
CREATE INDEX IF NOT EXISTS idx_logs_lead_type_time
ON omnichannel_logs (lead_id, event_type, created_at DESC);
-- Partial index for fast SENTIMENT_SPIKE lookups
CREATE INDEX IF NOT EXISTS idx_logs_sentiment_spikes
ON omnichannel_logs (lead_id, created_at DESC)
WHERE event_type = 'SENTIMENT_SPIKE';
-- ────────────────────────────────────────────────────────────────────────────
-- TABLE: consent_log (GDPR biometric consent tracking)
-- ────────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS consent_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
lead_id UUID REFERENCES leads_intelligence(id) ON DELETE CASCADE,
consented_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ip_address TEXT,
user_agent TEXT,
-- 'granted' or 'revoked'
action TEXT NOT NULL DEFAULT 'granted'
);
CREATE INDEX IF NOT EXISTS idx_consent_lead ON consent_log (lead_id, consented_at DESC);

View File

@@ -0,0 +1,85 @@
-- ────────────────────────────────────────────────────────────────────────────
-- Addendum: Video Scene Maps (video_timestamp → room label mapping)
-- Appended to schema.sql for Sprint 1 milestone.
-- ────────────────────────────────────────────────────────────────────────────
-- TABLE: video_scene_maps
-- Stores the timestamp-to-room mapping for each marketing video.
-- Uploaded once per inventory item (CSV parsed and inserted by the API).
-- Format: scene_no, start_ms, end_ms, room_type, description
-- This allows NemoClaw to correlate a biometric reaction at T=45000ms with
-- "Master Bedroom" for contextual QD scoring.
CREATE TABLE IF NOT EXISTS video_scene_maps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
video_asset_id TEXT NOT NULL, -- Matches inventory item slug / asset filename
scene_no INTEGER NOT NULL,
start_ms BIGINT NOT NULL,
end_ms BIGINT NOT NULL,
room_type TEXT NOT NULL, -- e.g. 'Living Room', 'Master Bedroom', 'Balcony'
description TEXT, -- Optional: 'Ocean-facing balcony with pool view'
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (video_asset_id, scene_no)
);
CREATE INDEX IF NOT EXISTS idx_scenes_asset_range
ON video_scene_maps (video_asset_id, start_ms, end_ms);
-- ────────────────────────────────────────────────────────────────────────────
-- TABLE: perception_sessions
-- Tracks each PerceptionPlayer session (assigned or auto mode).
-- Assigned Mode: lead_id is set before session starts.
-- Auto Mode : lead_id is NULL; auto_mode_matched_at populated post hoc.
-- ────────────────────────────────────────────────────────────────────────────
DO $$ BEGIN
CREATE TYPE session_mode_enum AS ENUM ('assigned', 'auto');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
CREATE TABLE IF NOT EXISTS perception_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_mode session_mode_enum NOT NULL DEFAULT 'assigned',
lead_id UUID REFERENCES leads_intelligence(id) ON DELETE SET NULL,
video_asset_id TEXT NOT NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ended_at TIMESTAMPTZ,
final_qd_score INTEGER CHECK (final_qd_score BETWEEN 1 AND 100),
-- For auto mode: the lead_id matched after session by face/plate recognition
auto_mode_matched_at TIMESTAMPTZ,
-- JSONB blob with auto-mode gathered data: face_hash, plate, vehicle_class, etc.
auto_mode_evidence JSONB DEFAULT '{}',
broker_user_id UUID REFERENCES users_and_roles(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_sessions_lead ON perception_sessions (lead_id, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_sessions_unmatched
ON perception_sessions (started_at DESC)
WHERE session_mode = 'auto' AND lead_id IS NULL;
-- ────────────────────────────────────────────────────────────────────────────
-- TABLE: cctv_events
-- Records each parking/entry visitor event from CCTV feeds.
-- License plates, vehicle class, NemoClaw wealth indicator.
-- ────────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS cctv_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
zone TEXT NOT NULL, -- 'Parking Entry', 'Main Gate', 'Zone A', etc.
license_plate TEXT, -- Raw OCR text
vehicle_class TEXT, -- 'luxury' | 'standard' | 'unknown'
wealth_indicator TEXT, -- 'HNI' | 'standard' | 'unknown'
nemoclaw_tags TEXT[] NOT NULL DEFAULT '{}',
nemoclaw_notes TEXT,
linked_lead_id UUID REFERENCES leads_intelligence(id) ON DELETE SET NULL,
linked_session_id UUID REFERENCES perception_sessions(id) ON DELETE SET NULL,
captured_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
raw_payload JSONB NOT NULL DEFAULT '{}' -- Full CCTV frame metadata
);
CREATE INDEX IF NOT EXISTS idx_cctv_plate ON cctv_events (license_plate, captured_at DESC);
CREATE INDEX IF NOT EXISTS idx_cctv_zone ON cctv_events (zone, captured_at DESC);
CREATE INDEX IF NOT EXISTS idx_cctv_unlinked
ON cctv_events (captured_at DESC)
WHERE linked_lead_id IS NULL;

100
core/db/db/schema_comms.sql Normal file
View File

@@ -0,0 +1,100 @@
-- Velocity Comms Schema
-- Run this migration against your asyncpg pool database.
BEGIN;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- Threads (conversations)
CREATE TABLE IF NOT EXISTS comms_threads (
thread_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
provider TEXT NOT NULL DEFAULT 'mock',
external_thread_id TEXT,
person_id UUID NULL REFERENCES crm_people(person_id) ON DELETE SET NULL,
phone_e164 TEXT NOT NULL,
display_name TEXT,
channel TEXT NOT NULL DEFAULT 'whatsapp',
status TEXT NOT NULL DEFAULT 'open',
assigned_user_id UUID NULL,
last_message_at TIMESTAMPTZ,
unread_count INT NOT NULL DEFAULT 0,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_comms_threads_phone ON comms_threads(phone_e164);
CREATE INDEX IF NOT EXISTS idx_comms_threads_person ON comms_threads(person_id) WHERE person_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_comms_threads_status ON comms_threads(status);
CREATE INDEX IF NOT EXISTS idx_comms_threads_last_message ON comms_threads(last_message_at DESC NULLS LAST);
-- Messages
CREATE TABLE IF NOT EXISTS comms_messages (
message_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
thread_id UUID NOT NULL REFERENCES comms_threads(thread_id) ON DELETE CASCADE,
provider TEXT NOT NULL DEFAULT 'mock',
external_message_id TEXT,
direction TEXT NOT NULL CHECK (direction IN ('inbound', 'outbound', 'system')),
message_type TEXT NOT NULL DEFAULT 'text',
body TEXT NOT NULL DEFAULT '',
media_url TEXT,
media_mime_type TEXT,
delivery_status TEXT NOT NULL DEFAULT 'pending',
sent_at TIMESTAMPTZ,
delivered_at TIMESTAMPTZ,
read_at TIMESTAMPTZ,
raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_comms_messages_thread ON comms_messages(thread_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_comms_messages_external ON comms_messages(external_message_id) WHERE external_message_id IS NOT NULL;
-- Call logs
CREATE TABLE IF NOT EXISTS comms_call_logs (
call_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
thread_id UUID NULL REFERENCES comms_threads(thread_id) ON DELETE SET NULL,
person_id UUID NULL REFERENCES crm_people(person_id) ON DELETE SET NULL,
provider TEXT NOT NULL DEFAULT 'mock',
external_call_id TEXT,
phone_e164 TEXT NOT NULL,
direction TEXT NOT NULL CHECK (direction IN ('inbound', 'outbound')),
status TEXT NOT NULL DEFAULT 'completed',
started_at TIMESTAMPTZ NOT NULL,
ended_at TIMESTAMPTZ,
duration_seconds INT,
recording_url TEXT,
transcript_id UUID,
transcript_text TEXT,
raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_comms_call_logs_phone ON comms_call_logs(phone_e164);
CREATE INDEX IF NOT EXISTS idx_comms_call_logs_thread ON comms_call_logs(thread_id) WHERE thread_id IS NOT NULL;
-- Settings (key-value JSON)
CREATE TABLE IF NOT EXISTS comms_settings (
key TEXT PRIMARY KEY,
value_json JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Insert default settings
INSERT INTO comms_settings (key, value_json) VALUES ('config', '{
"provider": "mock",
"provider_base_url": "",
"provider_api_key": "",
"instance_id": "",
"phone_number_id": "",
"webhook_callback_url": "",
"webhook_secret_set": false,
"default_assignment_user_id": null,
"auto_link_by_phone": true,
"create_crm_interaction_on_inbound": true,
"default_country_code": "91",
"media_storage_dir": "/opt/dlami/nvme/assets/comms",
"transcription_provider": "none"
}'::jsonb) ON CONFLICT (key) DO NOTHING;
COMMIT;

View File

@@ -0,0 +1,923 @@
-- =============================================================================
-- schema_crm_canonical.sql
-- Project Velocity — Canonical CRM and Platform Schema
-- =============================================================================
-- Covers: crm_*, intel_*, inventory_*, workflow_* canonical domains
-- as specified in Doc 09: Database Schema and Root API Spec
-- and Doc 07: Contracts and Schema Blueprint
--
-- Run AFTER schema.sql and schema_addendum.sql
-- psql -U velocity_user -d velocity_db -f schema_crm_canonical.sql
--
-- Existing tables: users_and_roles, leads_intelligence, velocity_vault_assets,
-- omnichannel_logs, consent_log, video_scene_maps,
-- perception_sessions, cctv_events, leads, chat_logs
-- These are treated as legacy feeders per the reconciliation matrix.
-- =============================================================================
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
-- ─────────────────────────────────────────────────────────────────────────────
-- ENUM TYPES — Canonical Domain
-- ─────────────────────────────────────────────────────────────────────────────
DO $$ BEGIN
CREATE TYPE crm_lead_status AS ENUM (
'new', 'contacted', 'qualified', 'site_visit_scheduled', 'site_visited',
'negotiation', 'booking_initiated', 'booked', 'lost', 'dormant'
);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE crm_opportunity_stage AS ENUM (
'prospect', 'qualified', 'proposal', 'site_visit', 'negotiation',
'booking', 'agreement', 'closed_won', 'closed_lost'
);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE crm_account_type AS ENUM (
'individual', 'company', 'broker', 'developer', 'referral_partner', 'nri_family'
);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE crm_relationship_type AS ENUM (
'spouse', 'parent', 'sibling', 'business_partner', 'broker_referral',
'co_buyer', 'family_member', 'advisor'
);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE intel_channel AS ENUM (
'whatsapp', 'phone', 'email', 'site_visit', 'office_meeting',
'video_call', 'cctv', 'perception_session', 'system'
);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE intel_call_direction AS ENUM ('inbound', 'outbound');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE wf_status AS ENUM (
'pending', 'review_required', 'approved', 'rejected', 'executed', 'failed', 'cancelled'
);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE import_lifecycle AS ENUM (
'uploaded', 'parsed', 'mapped', 'proposed', 'approved', 'committed', 'failed'
);
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
-- ─────────────────────────────────────────────────────────────────────────────
-- SECTION 1: CRM CORE DOMAIN (crm_*)
-- ─────────────────────────────────────────────────────────────────────────────
-- TABLE: crm_people
-- Purpose: canonical person-level contact identity
CREATE TABLE IF NOT EXISTS crm_people (
person_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
full_name TEXT NOT NULL,
primary_email TEXT,
primary_phone TEXT,
secondary_phone TEXT,
linkedin_url TEXT,
city TEXT,
nationality TEXT,
buyer_type TEXT, -- high_intent, slow_burn_investor, nri, etc.
persona_labels JSONB NOT NULL DEFAULT '[]'::jsonb,
source_confidence FLOAT CHECK (source_confidence BETWEEN 0.0 AND 1.0),
-- Legacy feeder references (migration linkage)
legacy_lead_id TEXT, -- links to old leads.id
legacy_li_id UUID, -- links to leads_intelligence.id
-- Metadata
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_crm_people_email ON crm_people (primary_email);
CREATE INDEX IF NOT EXISTS idx_crm_people_phone ON crm_people (primary_phone);
CREATE INDEX IF NOT EXISTS idx_crm_people_name_trgm ON crm_people USING GIN (full_name gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_crm_people_buyer_type ON crm_people (buyer_type);
-- TABLE: crm_accounts
-- Purpose: company, employer, brokerage, or client organization
CREATE TABLE IF NOT EXISTS crm_accounts (
account_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_name TEXT NOT NULL,
parent_account_id UUID REFERENCES crm_accounts(account_id) ON DELETE SET NULL,
account_type crm_account_type NOT NULL DEFAULT 'company',
industry TEXT,
location_ref TEXT,
website TEXT,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_crm_accounts_name ON crm_accounts (account_name);
CREATE INDEX IF NOT EXISTS idx_crm_accounts_type ON crm_accounts (account_type);
-- TABLE: crm_households
-- Purpose: family or co-buyer unit grouping
CREATE TABLE IF NOT EXISTS crm_households (
household_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
household_name TEXT NOT NULL,
primary_person_id UUID REFERENCES crm_people(person_id) ON DELETE SET NULL,
notes TEXT,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- TABLE: crm_relationships
-- Purpose: person-to-person relationship graph
CREATE TABLE IF NOT EXISTS crm_relationships (
relationship_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
person_a_id UUID NOT NULL REFERENCES crm_people(person_id) ON DELETE CASCADE,
person_b_id UUID NOT NULL REFERENCES crm_people(person_id) ON DELETE CASCADE,
relationship_type crm_relationship_type NOT NULL,
household_id UUID REFERENCES crm_households(household_id) ON DELETE SET NULL,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (person_a_id, person_b_id, relationship_type)
);
CREATE INDEX IF NOT EXISTS idx_crm_rel_a ON crm_relationships (person_a_id);
CREATE INDEX IF NOT EXISTS idx_crm_rel_b ON crm_relationships (person_b_id);
-- TABLE: crm_leads
-- Purpose: funnel-stage commercial qualification layer
CREATE TABLE IF NOT EXISTS crm_leads (
lead_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
person_id UUID NOT NULL REFERENCES crm_people(person_id) ON DELETE CASCADE,
account_id UUID REFERENCES crm_accounts(account_id) ON DELETE SET NULL,
source_system TEXT DEFAULT 'velocity',
status crm_lead_status NOT NULL DEFAULT 'new',
budget_band TEXT,
urgency TEXT, -- low, medium, high, critical
financing_posture TEXT, -- cash, loan, nri_remittance, emi
timeline_to_decision TEXT,
objections JSONB NOT NULL DEFAULT '[]'::jsonb,
motivations JSONB NOT NULL DEFAULT '[]'::jsonb,
assigned_user_id UUID REFERENCES users_and_roles(id) ON DELETE SET NULL,
-- Legacy feeder
legacy_lead_id TEXT,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_crm_leads_person ON crm_leads (person_id);
CREATE INDEX IF NOT EXISTS idx_crm_leads_status ON crm_leads (status);
CREATE INDEX IF NOT EXISTS idx_crm_leads_assigned ON crm_leads (assigned_user_id);
CREATE INDEX IF NOT EXISTS idx_crm_leads_source ON crm_leads (source_system);
-- TABLE: crm_opportunities
-- Purpose: deal pipeline objects
CREATE TABLE IF NOT EXISTS crm_opportunities (
opportunity_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
lead_id UUID NOT NULL REFERENCES crm_leads(lead_id) ON DELETE CASCADE,
project_id UUID, -- references inventory_projects
unit_id UUID, -- references inventory_units
stage crm_opportunity_stage NOT NULL DEFAULT 'prospect',
value DECIMAL(15, 2),
probability INTEGER CHECK (probability BETWEEN 0 AND 100),
expected_close_date DATE,
next_action TEXT,
notes TEXT,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_crm_opp_lead ON crm_opportunities (lead_id);
CREATE INDEX IF NOT EXISTS idx_crm_opp_stage ON crm_opportunities (stage);
CREATE INDEX IF NOT EXISTS idx_crm_opp_project ON crm_opportunities (project_id);
-- TABLE: crm_property_interests
-- Purpose: project and unit interest linking per client
CREATE TABLE IF NOT EXISTS crm_property_interests (
interest_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
person_id UUID NOT NULL REFERENCES crm_people(person_id) ON DELETE CASCADE,
lead_id UUID REFERENCES crm_leads(lead_id) ON DELETE SET NULL,
project_id UUID,
project_name TEXT NOT NULL,
unit_preference TEXT,
configuration TEXT, -- 2BHK, 3BHK, Penthouse, etc.
budget_min DECIMAL(15, 2),
budget_max DECIMAL(15, 2),
priority INTEGER DEFAULT 1, -- 1 = primary, 2 = secondary
notes TEXT,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE crm_property_interests
ADD COLUMN IF NOT EXISTS metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb;
CREATE INDEX IF NOT EXISTS idx_crm_pi_person ON crm_property_interests (person_id);
CREATE INDEX IF NOT EXISTS idx_crm_pi_project ON crm_property_interests (project_id);
-- TABLE: crm_stage_history
-- Purpose: canonical audit trail of lead stage transitions
CREATE TABLE IF NOT EXISTS crm_stage_history (
history_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
lead_id UUID NOT NULL REFERENCES crm_leads(lead_id) ON DELETE CASCADE,
from_status TEXT,
to_status TEXT NOT NULL,
changed_by UUID REFERENCES users_and_roles(id) ON DELETE SET NULL,
changed_by_type TEXT DEFAULT 'human', -- human, ai, system
notes TEXT,
happened_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_crm_stage_lead ON crm_stage_history (lead_id, happened_at DESC);
-- ─────────────────────────────────────────────────────────────────────────────
-- SECTION 2: INTERACTION AND EVIDENCE GRAPH (intel_*)
-- ─────────────────────────────────────────────────────────────────────────────
-- TABLE: intel_interactions
-- Purpose: umbrella interaction event record
CREATE TABLE IF NOT EXISTS intel_interactions (
interaction_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
person_id UUID NOT NULL REFERENCES crm_people(person_id) ON DELETE CASCADE,
lead_id UUID REFERENCES crm_leads(lead_id) ON DELETE SET NULL,
channel intel_channel NOT NULL,
interaction_type TEXT NOT NULL, -- message, call, visit, email, note
happened_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
summary TEXT,
source_ref TEXT,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_intel_int_person ON intel_interactions (person_id, happened_at DESC);
CREATE INDEX IF NOT EXISTS idx_intel_int_lead ON intel_interactions (lead_id, happened_at DESC);
CREATE INDEX IF NOT EXISTS idx_intel_int_channel ON intel_interactions (channel);
-- TABLE: intel_messages
-- Purpose: text-level message records (WhatsApp, chat)
CREATE TABLE IF NOT EXISTS intel_messages (
message_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
interaction_id UUID NOT NULL REFERENCES intel_interactions(interaction_id) ON DELETE CASCADE,
thread_id UUID,
sender_role TEXT NOT NULL, -- lead, broker, system, oracle
sender_name TEXT,
message_text TEXT NOT NULL,
delivered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX IF NOT EXISTS idx_intel_msg_interaction ON intel_messages (interaction_id, delivered_at DESC);
-- TABLE: intel_whatsapp_threads
-- Purpose: WhatsApp thread-level summaries
CREATE TABLE IF NOT EXISTS intel_whatsapp_threads (
thread_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
person_id UUID NOT NULL REFERENCES crm_people(person_id) ON DELETE CASCADE,
lead_id UUID REFERENCES crm_leads(lead_id) ON DELETE SET NULL,
phone_number TEXT,
thread_summary TEXT,
message_count INTEGER DEFAULT 0,
last_message_at TIMESTAMPTZ,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_intel_wa_person ON intel_whatsapp_threads (person_id);
-- TABLE: intel_calls
-- Purpose: voice call records
CREATE TABLE IF NOT EXISTS intel_calls (
call_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
interaction_id UUID NOT NULL REFERENCES intel_interactions(interaction_id) ON DELETE CASCADE,
call_direction intel_call_direction NOT NULL DEFAULT 'outbound',
duration_seconds INTEGER,
recording_ref TEXT, -- storage path or URL to recording
transcript_ref TEXT, -- path to transcript JSON
call_outcome TEXT, -- connected, no_answer, voicemail, dropped
called_number TEXT,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX IF NOT EXISTS idx_intel_call_interaction ON intel_calls (interaction_id);
-- TABLE: intel_transcripts
-- Purpose: transcript and speaker segmentation storage
CREATE TABLE IF NOT EXISTS intel_transcripts (
transcript_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
call_id UUID REFERENCES intel_calls(call_id) ON DELETE SET NULL,
interaction_id UUID REFERENCES intel_interactions(interaction_id) ON DELETE SET NULL,
language TEXT DEFAULT 'en',
full_text TEXT,
speaker_segments_json JSONB NOT NULL DEFAULT '[]'::jsonb,
confidence FLOAT CHECK (confidence BETWEEN 0.0 AND 1.0),
word_count INTEGER,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_intel_transcript_call ON intel_transcripts (call_id);
CREATE INDEX IF NOT EXISTS idx_intel_transcript_interaction ON intel_transcripts (interaction_id);
-- TABLE: intel_emails
-- Purpose: email thread records
CREATE TABLE IF NOT EXISTS intel_emails (
email_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
interaction_id UUID NOT NULL REFERENCES intel_interactions(interaction_id) ON DELETE CASCADE,
from_address TEXT,
to_addresses JSONB NOT NULL DEFAULT '[]'::jsonb,
subject TEXT,
body_text TEXT,
has_attachments BOOLEAN DEFAULT FALSE,
sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX IF NOT EXISTS idx_intel_email_interaction ON intel_emails (interaction_id);
-- TABLE: intel_visits
-- Purpose: site visit and meeting records
CREATE TABLE IF NOT EXISTS intel_visits (
visit_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
person_id UUID NOT NULL REFERENCES crm_people(person_id) ON DELETE CASCADE,
lead_id UUID REFERENCES crm_leads(lead_id) ON DELETE SET NULL,
project_id UUID,
project_name TEXT,
unit_id UUID,
visited_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
visit_notes TEXT,
host_user_id UUID REFERENCES users_and_roles(id) ON DELETE SET NULL,
revisit_intent TEXT, -- very_likely, likely, uncertain, unlikely
cctv_session_ref TEXT,
perception_session_ref TEXT,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX IF NOT EXISTS idx_intel_visits_person ON intel_visits (person_id, visited_at DESC);
CREATE INDEX IF NOT EXISTS idx_intel_visits_project ON intel_visits (project_id);
-- TABLE: intel_reminders
-- Purpose: reminders and follow-up task chains
CREATE TABLE IF NOT EXISTS intel_reminders (
reminder_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
person_id UUID NOT NULL REFERENCES crm_people(person_id) ON DELETE CASCADE,
lead_id UUID REFERENCES crm_leads(lead_id) ON DELETE SET NULL,
opportunity_id UUID REFERENCES crm_opportunities(opportunity_id) ON DELETE SET NULL,
reminder_type TEXT NOT NULL, -- call_back, follow_up, site_visit, document, negotiation
title TEXT NOT NULL,
notes TEXT,
due_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'pending', -- pending, done, snoozed, cancelled
assigned_to UUID REFERENCES users_and_roles(id) ON DELETE SET NULL,
created_by_type TEXT DEFAULT 'human', -- human, ai, system
priority TEXT DEFAULT 'normal', -- low, normal, high, urgent
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_intel_reminder_person ON intel_reminders (person_id, due_at);
CREATE INDEX IF NOT EXISTS idx_intel_reminder_status ON intel_reminders (status, due_at);
CREATE INDEX IF NOT EXISTS idx_intel_reminder_assigned ON intel_reminders (assigned_to, due_at);
-- TABLE: intel_qd_scores
-- Purpose: latest meaningful QD summary by client
CREATE TABLE IF NOT EXISTS intel_qd_scores (
qd_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
person_id UUID NOT NULL REFERENCES crm_people(person_id) ON DELETE CASCADE,
score_type TEXT NOT NULL, -- intent_score, urgency_score, engagement_score
current_value FLOAT NOT NULL CHECK (current_value BETWEEN 0.0 AND 1.0),
computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
evidence_refs_json JSONB NOT NULL DEFAULT '[]'::jsonb,
reasoning TEXT,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
UNIQUE (person_id, score_type)
);
CREATE INDEX IF NOT EXISTS idx_intel_qd_person ON intel_qd_scores (person_id);
-- TABLE: intel_qd_timeseries
-- Purpose: time-series QD propagation and shifts
CREATE TABLE IF NOT EXISTS intel_qd_timeseries (
timeseries_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
person_id UUID NOT NULL REFERENCES crm_people(person_id) ON DELETE CASCADE,
score_type TEXT NOT NULL,
signal_source TEXT,
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
value FLOAT NOT NULL CHECK (value BETWEEN 0.0 AND 1.0),
delta FLOAT,
evidence_ref TEXT,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX IF NOT EXISTS idx_intel_qd_ts_person ON intel_qd_timeseries (person_id, timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_intel_qd_ts_type ON intel_qd_timeseries (score_type, timestamp DESC);
-- TABLE: intel_vehicle_events
-- Purpose: number-plate and vehicle detection events
CREATE TABLE IF NOT EXISTS intel_vehicle_events (
event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
person_id UUID REFERENCES crm_people(person_id) ON DELETE SET NULL,
visit_id UUID REFERENCES intel_visits(visit_id) ON DELETE SET NULL,
zone TEXT,
license_plate_hash TEXT, -- hashed for privacy
vehicle_class TEXT, -- luxury, standard, unknown
wealth_indicator TEXT, -- HNI, standard, unknown
cctv_ref TEXT,
captured_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX IF NOT EXISTS idx_intel_vehicle_person ON intel_vehicle_events (person_id);
-- TABLE: intel_perception_events
-- Purpose: behavioral and dwell-time intelligence from perception sessions
CREATE TABLE IF NOT EXISTS intel_perception_events (
perception_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
person_id UUID REFERENCES crm_people(person_id) ON DELETE SET NULL,
visit_id UUID REFERENCES intel_visits(visit_id) ON DELETE SET NULL,
session_ref TEXT, -- perception_sessions.id linkage
event_type TEXT NOT NULL, -- room_dwell, engagement_spike, exit
rooms_visited JSONB NOT NULL DEFAULT '[]'::jsonb,
dwell_time_seconds INTEGER,
engagement_score FLOAT CHECK (engagement_score BETWEEN 0.0 AND 1.0),
camera_id TEXT,
media_ref TEXT,
happened_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX IF NOT EXISTS idx_intel_perception_person ON intel_perception_events (person_id);
-- TABLE: intel_cctv_links
-- Purpose: CCTV evidence references linked to client/visit contexts
CREATE TABLE IF NOT EXISTS intel_cctv_links (
link_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
person_id UUID REFERENCES crm_people(person_id) ON DELETE SET NULL,
visit_id UUID REFERENCES intel_visits(visit_id) ON DELETE SET NULL,
cctv_event_id UUID REFERENCES cctv_events(id) ON DELETE SET NULL,
clip_ref TEXT,
camera_zone TEXT,
confidence FLOAT CHECK (confidence BETWEEN 0.0 AND 1.0),
linked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX IF NOT EXISTS idx_intel_cctv_person ON intel_cctv_links (person_id);
-- ─────────────────────────────────────────────────────────────────────────────
-- SECTION 3: INVENTORY DOMAIN (inventory_*)
-- ─────────────────────────────────────────────────────────────────────────────
-- TABLE: inventory_projects
-- Purpose: project-level inventory master
CREATE TABLE IF NOT EXISTS inventory_projects (
project_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_name TEXT NOT NULL UNIQUE,
developer_name TEXT NOT NULL,
city TEXT NOT NULL DEFAULT 'Kolkata',
micro_market TEXT,
address TEXT,
total_units INTEGER,
rera_number TEXT,
project_status TEXT DEFAULT 'active', -- active, sold_out, upcoming
launch_date DATE,
possession_date DATE,
location_json JSONB NOT NULL DEFAULT '{}'::jsonb,
amenities_json JSONB NOT NULL DEFAULT '[]'::jsonb,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_inv_projects_name ON inventory_projects (project_name);
CREATE INDEX IF NOT EXISTS idx_inv_projects_market ON inventory_projects (micro_market);
-- TABLE: inventory_units
-- Purpose: unit-level availability and attributes
CREATE TABLE IF NOT EXISTS inventory_units (
unit_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES inventory_projects(project_id) ON DELETE CASCADE,
unit_label TEXT NOT NULL,
configuration TEXT NOT NULL, -- 2BHK, 3BHK, Penthouse, etc.
area_sqft DECIMAL(10, 2),
price_current DECIMAL(15, 2),
price_psf DECIMAL(10, 2),
status TEXT NOT NULL DEFAULT 'available', -- available, reserved, sold, hold
floor INTEGER,
tower TEXT,
facing TEXT,
has_attached_amenities JSONB NOT NULL DEFAULT '[]'::jsonb,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (project_id, unit_label)
);
CREATE INDEX IF NOT EXISTS idx_inv_units_project ON inventory_units (project_id);
CREATE INDEX IF NOT EXISTS idx_inv_units_status ON inventory_units (status);
CREATE INDEX IF NOT EXISTS idx_inv_units_config ON inventory_units (configuration);
-- TABLE: inventory_import_jobs
-- Purpose: track inventory CSV import operations
CREATE TABLE IF NOT EXISTS inventory_import_jobs (
job_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID REFERENCES inventory_projects(project_id) ON DELETE SET NULL,
filename TEXT NOT NULL,
row_count INTEGER,
imported_by UUID REFERENCES users_and_roles(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'pending',
errors_json JSONB NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
-- ─────────────────────────────────────────────────────────────────────────────
-- SECTION 4: AI WORKFLOW AND GOVERNANCE (workflow_*)
-- ─────────────────────────────────────────────────────────────────────────────
-- TABLE: workflow_actions
-- Purpose: track proposed AI/human actions before approval
CREATE TABLE IF NOT EXISTS workflow_actions (
action_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
action_type TEXT NOT NULL, -- import_review, merge_proposal, writeback, enrichment
target_domain TEXT NOT NULL, -- crm, intel, inventory
target_entity_ref TEXT,
proposal_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
reasoning_summary TEXT,
evidence_refs JSONB NOT NULL DEFAULT '[]'::jsonb,
confidence FLOAT CHECK (confidence BETWEEN 0.0 AND 1.0),
status wf_status NOT NULL DEFAULT 'pending',
approval_required BOOLEAN NOT NULL DEFAULT TRUE,
created_by_agent TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_wf_actions_status ON workflow_actions (status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_wf_actions_domain ON workflow_actions (target_domain);
-- TABLE: workflow_approvals
-- Purpose: explicit human review decisions
CREATE TABLE IF NOT EXISTS workflow_approvals (
decision_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
action_id UUID NOT NULL REFERENCES workflow_actions(action_id) ON DELETE CASCADE,
reviewer_id UUID REFERENCES users_and_roles(id) ON DELETE SET NULL,
decision TEXT NOT NULL, -- approved, rejected, needs_more_info
decision_notes TEXT,
decided_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_wf_approvals_action ON workflow_approvals (action_id);
-- TABLE: workflow_writebacks
-- Purpose: track AI-suggested and approved canonical mutations
CREATE TABLE IF NOT EXISTS workflow_writebacks (
writeback_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
action_id UUID REFERENCES workflow_actions(action_id) ON DELETE SET NULL,
approval_id UUID REFERENCES workflow_approvals(decision_id) ON DELETE SET NULL,
target_domain TEXT NOT NULL,
target_entity_ref TEXT NOT NULL,
change_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
status wf_status NOT NULL DEFAULT 'pending',
approved_by UUID REFERENCES users_and_roles(id) ON DELETE SET NULL,
executed_at TIMESTAMPTZ,
error_detail TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_wf_wb_status ON workflow_writebacks (status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_wf_wb_domain ON workflow_writebacks (target_domain);
-- TABLE: workflow_import_batches
-- Purpose: CRM import batch lifecycle tracking (RawImportBatch contract)
CREATE TABLE IF NOT EXISTS workflow_import_batches (
batch_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
source_system TEXT NOT NULL, -- csv_upload, salesforce, hubspot, manual
uploaded_filename TEXT,
mime_type TEXT DEFAULT 'text/csv',
storage_ref TEXT,
row_count INTEGER,
mapped_count INTEGER DEFAULT 0,
unresolved_count INTEGER DEFAULT 0,
canonical_count INTEGER DEFAULT 0,
uploaded_by UUID REFERENCES users_and_roles(id) ON DELETE SET NULL,
lifecycle import_lifecycle NOT NULL DEFAULT 'uploaded',
mapping_manifest JSONB NOT NULL DEFAULT '{}'::jsonb,
errors_json JSONB NOT NULL DEFAULT '[]'::jsonb,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_wf_import_lifecycle ON workflow_import_batches (lifecycle, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_wf_import_user ON workflow_import_batches (uploaded_by);
-- TABLE: workflow_agent_runs
-- Purpose: track NemoClaw and AI agent invocation logs
CREATE TABLE IF NOT EXISTS workflow_agent_runs (
run_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
agent_name TEXT NOT NULL, -- nemoclaw, import_mapper, enrichment_engine
trigger_type TEXT NOT NULL, -- import, enrichment, qd_update, writeback
trigger_ref TEXT,
input_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
output_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
status TEXT NOT NULL DEFAULT 'running', -- running, completed, failed
duration_ms INTEGER,
error_detail TEXT,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_wf_agent_runs_agent ON workflow_agent_runs (agent_name, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_wf_agent_runs_status ON workflow_agent_runs (status);
-- ─────────────────────────────────────────────────────────────────────────────
-- TENANT HARDENING FOR SHARED CRM SURFACES
-- ─────────────────────────────────────────────────────────────────────────────
ALTER TABLE crm_people ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
ALTER TABLE crm_accounts ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
ALTER TABLE crm_leads ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
ALTER TABLE crm_opportunities ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
ALTER TABLE crm_property_interests ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
ALTER TABLE intel_interactions ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
ALTER TABLE intel_reminders ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
ALTER TABLE intel_qd_scores ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
ALTER TABLE intel_qd_timeseries ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
ALTER TABLE workflow_actions ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
ALTER TABLE workflow_approvals ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
ALTER TABLE workflow_import_batches ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
CREATE INDEX IF NOT EXISTS idx_crm_people_tenant_created ON crm_people (tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_crm_leads_tenant_status ON crm_leads (tenant_id, status, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_crm_opportunities_tenant_stage ON crm_opportunities (tenant_id, stage, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_crm_property_interests_tenant_person ON crm_property_interests (tenant_id, person_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_intel_interactions_tenant_person ON intel_interactions (tenant_id, person_id, happened_at DESC);
CREATE INDEX IF NOT EXISTS idx_intel_reminders_tenant_status ON intel_reminders (tenant_id, status, due_at);
CREATE INDEX IF NOT EXISTS idx_intel_qd_scores_tenant_person ON intel_qd_scores (tenant_id, person_id, score_type);
CREATE INDEX IF NOT EXISTS idx_intel_qd_timeseries_tenant_person ON intel_qd_timeseries (tenant_id, person_id, timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_wf_actions_tenant_status ON workflow_actions (tenant_id, status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_wf_import_batches_tenant_lifecycle ON workflow_import_batches (tenant_id, lifecycle, created_at DESC);
-- ─────────────────────────────────────────────────────────────────────────────
-- TRIGGERS: auto-update updated_at
-- ─────────────────────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION set_canonical_updated_at()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$;
DO $$ DECLARE
t TEXT;
BEGIN
FOREACH t IN ARRAY ARRAY[
'crm_people', 'crm_accounts', 'crm_leads', 'crm_opportunities',
'inventory_projects', 'inventory_units',
'workflow_actions', 'workflow_import_batches'
] LOOP
EXECUTE format(
'DROP TRIGGER IF EXISTS trg_%s_updated_at ON %s;
CREATE TRIGGER trg_%s_updated_at
BEFORE UPDATE ON %s
FOR EACH ROW EXECUTE FUNCTION set_canonical_updated_at();',
t, t, t, t
);
END LOOP;
END $$;
-- ─────────────────────────────────────────────────────────────────────────────
-- INVENTORY SEED: 14 Canonical Kolkata Projects
-- ─────────────────────────────────────────────────────────────────────────────
INSERT INTO inventory_projects (project_id, project_name, developer_name, city, micro_market)
VALUES
(gen_random_uuid(), 'Eden Devprayag', 'Eden Group', 'Kolkata', 'Rajarhat'),
(gen_random_uuid(), 'Sugam Prakriti', 'Sugam Homes', 'Kolkata', 'Barasat'),
(gen_random_uuid(), 'Atri Aqua', 'Atri Developers', 'Kolkata', 'New Town'),
(gen_random_uuid(), 'Atri Surya Toron', 'Atri Developers', 'Kolkata', 'Rajarhat'),
(gen_random_uuid(), 'Siddha Suburbia Bungalow', 'Siddha Group', 'Kolkata', 'Madanpur'),
(gen_random_uuid(), 'Merlin Avana', 'Merlin Group', 'Kolkata', 'Tangra'),
(gen_random_uuid(), 'DTC Good Earth', 'DTC Projects', 'Kolkata', 'New Town'),
(gen_random_uuid(), 'Siddha Serena', 'Siddha Group', 'Kolkata', 'New Town'),
(gen_random_uuid(), 'Siddha Sky Waterfront', 'Siddha Group', 'Kolkata', 'Beliaghata'),
(gen_random_uuid(), 'Godrej Blue', 'Godrej Properties', 'Kolkata', 'New Town'),
(gen_random_uuid(), 'DTC Sojon', 'DTC Projects', 'Kolkata', 'Rajarhat'),
(gen_random_uuid(), 'Shriram Grand City', 'Shriram Properties', 'Kolkata', 'Howrah'),
(gen_random_uuid(), 'Godrej Elevate', 'Godrej Properties', 'Kolkata', 'Dum Dum'),
(gen_random_uuid(), 'Ambuja Utpaala', 'Ambuja Neotia', 'Kolkata', 'Tollygunge')
ON CONFLICT (project_name) DO NOTHING;
-- ─────────────────────────────────────────────────────────────────────────────
-- COMMENTS
-- ─────────────────────────────────────────────────────────────────────────────
COMMENT ON TABLE crm_people IS 'Canonical person-level contact identity. Primary join key across all CRM tables.';
COMMENT ON TABLE crm_leads IS 'Funnel-stage commercial qualification. One person may have multiple lead contexts.';
COMMENT ON TABLE crm_opportunities IS 'Deal pipeline objects linked to leads and inventory.';
COMMENT ON TABLE intel_interactions IS 'Umbrella interaction event. All channels (WhatsApp, call, email, visit) link here.';
COMMENT ON TABLE intel_transcripts IS 'Speaker-segmented call transcripts. speaker_segments_json is first-class data.';
COMMENT ON TABLE intel_qd_scores IS 'Latest QD summary by score_type per client. UNIQUE constraint enforces one row per type.';
COMMENT ON TABLE inventory_projects IS 'Master project catalog. 14 canonical Kolkata projects seeded.';
COMMENT ON TABLE workflow_import_batches IS 'RawImportBatch contract. Immutable after upload.';
COMMENT ON TABLE workflow_writebacks IS 'AI-proposed canonical mutations. Never auto-execute without approval.';
-- -----------------------------------------------------------------------------
-- Synthetic CRM v2 enrichment columns and Oracle read models
-- -----------------------------------------------------------------------------
ALTER TABLE crm_people
ADD COLUMN IF NOT EXISTS broker_id TEXT,
ADD COLUMN IF NOT EXISTS broker_name TEXT,
ADD COLUMN IF NOT EXISTS engagement_score FLOAT,
ADD COLUMN IF NOT EXISTS communication_preference TEXT,
ADD COLUMN IF NOT EXISTS best_contact_time TEXT;
ALTER TABLE crm_households
ADD COLUMN IF NOT EXISTS size INTEGER,
ADD COLUMN IF NOT EXISTS combined_budget_band TEXT,
ADD COLUMN IF NOT EXISTS decision_maker_id UUID REFERENCES crm_people(person_id) ON DELETE SET NULL;
ALTER TABLE crm_leads
ADD COLUMN IF NOT EXISTS stage TEXT,
ADD COLUMN IF NOT EXISTS broker_name TEXT,
ADD COLUMN IF NOT EXISTS broker_team TEXT,
ADD COLUMN IF NOT EXISTS engagement_score FLOAT,
ADD COLUMN IF NOT EXISTS last_activity_at TIMESTAMPTZ;
ALTER TABLE crm_opportunities
ADD COLUMN IF NOT EXISTS broker_id TEXT,
ADD COLUMN IF NOT EXISTS broker_name TEXT,
ADD COLUMN IF NOT EXISTS deal_velocity TEXT,
ADD COLUMN IF NOT EXISTS risk_factors JSONB NOT NULL DEFAULT '[]'::jsonb;
ALTER TABLE crm_property_interests
ADD COLUMN IF NOT EXISTS broker_id TEXT,
ADD COLUMN IF NOT EXISTS broker_name TEXT,
ADD COLUMN IF NOT EXISTS last_discussed_at TIMESTAMPTZ;
ALTER TABLE crm_stage_history
ADD COLUMN IF NOT EXISTS broker_name TEXT,
ADD COLUMN IF NOT EXISTS transition_duration_days INTEGER;
ALTER TABLE intel_interactions
ADD COLUMN IF NOT EXISTS broker_id TEXT,
ADD COLUMN IF NOT EXISTS broker_name TEXT,
ADD COLUMN IF NOT EXISTS broker_team TEXT,
ADD COLUMN IF NOT EXISTS sentiment TEXT,
ADD COLUMN IF NOT EXISTS sentiment_score FLOAT,
ADD COLUMN IF NOT EXISTS intent_label TEXT,
ADD COLUMN IF NOT EXISTS emotion_tags JSONB NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS client_engagement_level TEXT;
ALTER TABLE intel_calls
ADD COLUMN IF NOT EXISTS objection_tags JSONB NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS outcome_summary TEXT,
ADD COLUMN IF NOT EXISTS follow_up_actions JSONB NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS broker_id TEXT,
ADD COLUMN IF NOT EXISTS broker_name TEXT,
ADD COLUMN IF NOT EXISTS call_quality_score FLOAT;
ALTER TABLE intel_emails
ADD COLUMN IF NOT EXISTS sentiment TEXT,
ADD COLUMN IF NOT EXISTS intent_label TEXT,
ADD COLUMN IF NOT EXISTS engagement_score FLOAT,
ADD COLUMN IF NOT EXISTS broker_id TEXT,
ADD COLUMN IF NOT EXISTS broker_name TEXT,
ADD COLUMN IF NOT EXISTS response_expected BOOLEAN;
ALTER TABLE intel_reminders
ADD COLUMN IF NOT EXISTS interaction_id UUID REFERENCES intel_interactions(interaction_id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS context_snippet TEXT,
ADD COLUMN IF NOT EXISTS completion_percentage INTEGER,
ADD COLUMN IF NOT EXISTS overdue_days INTEGER,
ADD COLUMN IF NOT EXISTS outcome_notes TEXT;
ALTER TABLE intel_transcripts
ADD COLUMN IF NOT EXISTS call_outcome TEXT,
ADD COLUMN IF NOT EXISTS follow_up_required BOOLEAN,
ADD COLUMN IF NOT EXISTS emotion_tags JSONB NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS call_summary TEXT;
ALTER TABLE intel_visits
ADD COLUMN IF NOT EXISTS outcome_type TEXT,
ADD COLUMN IF NOT EXISTS visit_duration_minutes INTEGER,
ADD COLUMN IF NOT EXISTS interest_signals JSONB NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS interest_score FLOAT,
ADD COLUMN IF NOT EXISTS companion_type TEXT,
ADD COLUMN IF NOT EXISTS companion_count INTEGER,
ADD COLUMN IF NOT EXISTS objections_raised JSONB NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS follow_up_required BOOLEAN,
ADD COLUMN IF NOT EXISTS next_steps TEXT,
ADD COLUMN IF NOT EXISTS broker_notes TEXT,
ADD COLUMN IF NOT EXISTS broker_id TEXT,
ADD COLUMN IF NOT EXISTS broker_name TEXT;
ALTER TABLE intel_whatsapp_threads
ADD COLUMN IF NOT EXISTS broker_id TEXT,
ADD COLUMN IF NOT EXISTS broker_name TEXT,
ADD COLUMN IF NOT EXISTS topic_category TEXT,
ADD COLUMN IF NOT EXISTS sentiment_direction TEXT,
ADD COLUMN IF NOT EXISTS resolution_status TEXT;
ALTER TABLE intel_qd_scores
ADD COLUMN IF NOT EXISTS score_drivers JSONB NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS trend_direction TEXT,
ADD COLUMN IF NOT EXISTS explanation TEXT,
ADD COLUMN IF NOT EXISTS confidence FLOAT;
ALTER TABLE intel_qd_timeseries
ADD COLUMN IF NOT EXISTS broker_id TEXT;
CREATE TABLE IF NOT EXISTS intel_email_threads (
thread_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
subject TEXT,
first_email_at TIMESTAMPTZ,
last_email_at TIMESTAMPTZ,
email_count INTEGER DEFAULT 0,
participants JSONB NOT NULL DEFAULT '[]'::jsonb,
status TEXT,
broker_id TEXT,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE TABLE IF NOT EXISTS intel_call_objections (
objection_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
call_id UUID REFERENCES intel_calls(call_id) ON DELETE CASCADE,
objection_type TEXT,
category TEXT,
severity TEXT,
status TEXT,
client_quote TEXT,
agent_response TEXT,
resolution_strategy TEXT,
extracted_at TIMESTAMPTZ,
confidence_score FLOAT,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE TABLE IF NOT EXISTS intel_extracted_facts (
fact_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
interaction_id UUID REFERENCES intel_interactions(interaction_id) ON DELETE SET NULL,
person_id UUID REFERENCES crm_people(person_id) ON DELETE CASCADE,
fact_type TEXT NOT NULL,
fact_value TEXT,
confidence FLOAT,
extracted_from TEXT,
source_context TEXT,
extracted_at TIMESTAMPTZ,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE TABLE IF NOT EXISTS read_last_contacted (
person_id UUID PRIMARY KEY REFERENCES crm_people(person_id) ON DELETE CASCADE,
last_contact_at TIMESTAMPTZ,
last_channel TEXT,
last_interaction_type TEXT,
days_since_contact INTEGER,
interactions_last_7d INTEGER,
interactions_last_30d INTEGER,
interactions_last_90d INTEGER,
total_interactions INTEGER,
current_stage TEXT,
broker_id TEXT,
broker_name TEXT,
computed_at TIMESTAMPTZ,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE TABLE IF NOT EXISTS read_next_best_action (
person_id UUID PRIMARY KEY REFERENCES crm_people(person_id) ON DELETE CASCADE,
recommended_action TEXT,
priority TEXT,
rationale TEXT,
suggested_channel TEXT,
due_within_days INTEGER,
broker_id TEXT,
broker_name TEXT,
opportunity_context TEXT,
computed_at TIMESTAMPTZ,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX IF NOT EXISTS idx_read_last_contacted_at ON read_last_contacted (last_contact_at DESC);
CREATE INDEX IF NOT EXISTS idx_read_next_best_priority ON read_next_best_action (priority);
CREATE INDEX IF NOT EXISTS idx_intel_facts_person ON intel_extracted_facts (person_id, extracted_at DESC);
CREATE INDEX IF NOT EXISTS idx_intel_objections_call ON intel_call_objections (call_id);

141
core/db/seed_test_users.py Normal file
View File

@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""
seed_test_users.py — Velocity-OS Test Credential Seeder
========================================================
FOR TESTING ONLY. Never include in production build.
Inserts 7 test broker accounts into users_and_roles.
All passwords follow the pattern: Name@Velocity26
All emails follow: name@desineuron.in
Run against the local or remote Velocity-OS PostgreSQL:
python seed_test_users.py # uses env vars
python seed_test_users.py --dsn "postgresql://..." # explicit DSN
"""
import argparse
import hashlib
import os
import sys
# ── Generate bcrypt hashes OFFLINE (no DB dep) ────────────────────────────
# Uses passlib with same config as backend/auth/dependencies.py
try:
from passlib.context import CryptContext
pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(plain: str) -> str:
raw = plain.encode("utf-8")
if len(raw) > 72:
plain = raw[:72].decode("utf-8", errors="ignore")
return pwd_ctx.hash(plain)
except ImportError:
print("[ERROR] passlib not installed. Run: pip install passlib[bcrypt]")
sys.exit(1)
# ── Test user definitions ─────────────────────────────────────────────────
# Format: (full_name, email, plain_password, role)
TEST_USERS = [
("Sagnik Ghosh", "sagnik@desineuron.in", "Sagnik@Velocity26", "ADMIN"),
("Sayan Ghosh", "sayan@desineuron.in", "Sayan@Velocity26", "SALES_DIRECTOR"),
("Sourik Ghosh", "sourik@desineuron.in", "Sourik@Velocity26", "SENIOR_BROKER"),
("Abantika Das", "abantika@desineuron.in", "Abantika@Velocity26", "SENIOR_BROKER"),
("Sinjini Roy", "sinjini@desineuron.in", "Sinjini@Velocity26", "JUNIOR_BROKER"),
("Swastika Ghosh", "swastika@desineuron.in", "Swastika@Velocity26", "JUNIOR_BROKER"),
("Debargha Mukherjee","debargha@desineuron.in", "Debargha@Velocity26", "JUNIOR_BROKER"),
]
TENANT_ID = "tenant_velocity"
def build_sql() -> str:
"""Generate idempotent INSERT SQL (ON CONFLICT DO NOTHING)."""
lines = [
"-- ================================================================",
"-- Velocity-OS Test Users Seed (FOR TESTING ONLY — NOT FOR PROD)",
"-- ================================================================",
"-- Generated by seed_test_users.py",
"",
"BEGIN;",
"",
]
for full_name, email, plain, role in TEST_USERS:
pw_hash = hash_password(plain)
lines.append(f"-- {full_name} ({role})")
lines.append("INSERT INTO users_and_roles")
lines.append(" (email, password_hash, role, tenant_id, full_name, is_active)")
lines.append("VALUES")
lines.append(f" ('{email}', '{pw_hash}', '{role}', '{TENANT_ID}', '{full_name}', TRUE)")
lines.append("ON CONFLICT (email) DO UPDATE")
lines.append(" SET password_hash = EXCLUDED.password_hash,")
lines.append(" role = EXCLUDED.role,")
lines.append(" full_name = EXCLUDED.full_name,")
lines.append(" is_active = TRUE;")
lines.append("")
lines.append("COMMIT;")
lines.append("")
lines.append("-- Verify:")
lines.append("SELECT email, role, full_name FROM users_and_roles ORDER BY role DESC, email;")
return "\n".join(lines)
def run_against_db(dsn: str, sql: str) -> None:
try:
import asyncpg
import asyncio
async def _insert():
conn = await asyncpg.connect(dsn)
try:
await conn.execute(sql)
print("[OK] Test users inserted successfully.")
finally:
await conn.close()
asyncio.run(_insert())
except ImportError:
print("[WARN] asyncpg not installed — writing SQL file only.")
write_sql_file(sql)
def write_sql_file(sql: str) -> None:
out = os.path.join(os.path.dirname(__file__), "seed_test_users.sql")
with open(out, "w", encoding="utf-8") as f:
f.write(sql)
print(f"[OK] SQL written to: {out}")
print(" Apply with: psql -U velocity_user -d velocity_db -f seed_test_users.sql")
def main():
parser = argparse.ArgumentParser(description="Seed Velocity-OS test users")
parser.add_argument("--dsn", help="PostgreSQL DSN (overrides env VELOCITY_DB_DSN)")
parser.add_argument("--sql-only", action="store_true", help="Only write SQL file, don't connect")
args = parser.parse_args()
sql = build_sql()
print("Generating bcrypt hashes for test users...")
print("Users to seed:")
for full_name, email, plain, role in TEST_USERS:
print(f" [{role:16}] {email:30} / {plain}")
print()
if args.sql_only:
write_sql_file(sql)
return
dsn = args.dsn or os.getenv("VELOCITY_DB_DSN") or os.getenv("DATABASE_URL")
if dsn:
run_against_db(dsn, sql)
else:
print("[INFO] No DSN provided — writing SQL file for manual application.")
write_sql_file(sql)
if __name__ == "__main__":
main()