forked from sagnik/Velocity-OS
Initial commit: Velocity-OS migration
This commit is contained in:
1
core/db/db/__init__.py
Normal file
1
core/db/db/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""backend.db package"""
|
||||
58
core/db/db/pool.py
Normal file
58
core/db/db/pool.py
Normal 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
181
core/db/db/schema.sql
Normal 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);
|
||||
85
core/db/db/schema_addendum.sql
Normal file
85
core/db/db/schema_addendum.sql
Normal 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
100
core/db/db/schema_comms.sql
Normal 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;
|
||||
923
core/db/db/schema_crm_canonical.sql
Normal file
923
core/db/db/schema_crm_canonical.sql
Normal 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
141
core/db/seed_test_users.py
Normal 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()
|
||||
Reference in New Issue
Block a user