forked from sagnik/Project_Velocity
feat: Ipad app production readiness, Colony orchestration, Social posting (#44)
#38 Ipad app production readiness, Colony orchestration, Social posting Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local> Reviewed-on: sagnik/Project_Velocity#44
This commit is contained in:
@@ -7,6 +7,11 @@ META_ACCESS_TOKEN=PLACEHOLDER_your_meta_system_user_token
|
||||
# Meta Business Manager → Ad Accounts
|
||||
META_AD_ACCOUNT_ID=PLACEHOLDER_act_1234567890
|
||||
|
||||
# Page publishing values for Catalyst social posting
|
||||
META_PAGE_ACCESS_TOKEN=PLACEHOLDER_your_meta_page_access_token
|
||||
META_PAGE_ID=PLACEHOLDER_1234567890
|
||||
META_INSTAGRAM_BUSINESS_ID=PLACEHOLDER_17841400000000000
|
||||
|
||||
# Business Portfolio ID
|
||||
# Meta Business Settings → Business Info → Business ID
|
||||
META_BUSINESS_ID=PLACEHOLDER_1234567890
|
||||
@@ -32,6 +37,16 @@ SUPABASE_SERVICE_ROLE_KEY=PLACEHOLDER_your_supabase_service_role_key
|
||||
# Base URL of ComfyUI server running locally or on GPU node
|
||||
COMFY_BASE_URL=http://localhost:8188
|
||||
|
||||
# ── Colony Orchestration ─────────────────────────────────────────────────────
|
||||
# Real colony orchestrator service URL. Required; no local mock fallback is used.
|
||||
COLONY_SERVICE_URL=PLACEHOLDER_http://localhost:8090
|
||||
COLONY_TIMEOUT_SECONDS=30
|
||||
|
||||
# ── Social Posting ───────────────────────────────────────────────────────────
|
||||
LINKEDIN_ACCESS_TOKEN=PLACEHOLDER_your_linkedin_access_token
|
||||
LINKEDIN_ORG_ID=PLACEHOLDER_123456
|
||||
TWITTER_BEARER_TOKEN=PLACEHOLDER_your_twitter_bearer_token
|
||||
|
||||
# —— Shared Desineuron coding / Oracle / NemoClaw runtime —————————————————————
|
||||
# Stable OpenAI-compatible SGLang route rendered through ingress.
|
||||
LLM_BASE_URL=https://llm.desineuron.in
|
||||
|
||||
249
backend/.env.production.template
Normal file
249
backend/.env.production.template
Normal file
@@ -0,0 +1,249 @@
|
||||
# Project Velocity production environment template.
|
||||
# Copy to backend/.env.production on the deployment host, or map these names into
|
||||
# your secrets manager / systemd EnvironmentFile. Keep real values out of git.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Runtime / Deployment
|
||||
# -----------------------------------------------------------------------------
|
||||
ENVIRONMENT=production
|
||||
VELOCITY_ENV_FILE=/opt/velocity/backend/.env.production
|
||||
VELOCITY_PUBLIC_BACKEND_URL=https://api.desineuron.in
|
||||
VELOCITY_API_BASE_URL=https://api.desineuron.in
|
||||
VELOCITY_DREAM_WEAVER_URL=https://dreamweaver.desineuron.in
|
||||
VELOCITY_DEFAULT_TENANT_ID=tenant_velocity
|
||||
VELOCITY_DEMO_TENANT_ID=tenant_velocity
|
||||
VELOCITY_DEMO_OPERATOR_EMAIL=
|
||||
CORS_ORIGINS=https://velocity.desineuron.in,https://api.desineuron.in
|
||||
TRUSTED_HOSTS=api.desineuron.in,dreamweaver.desineuron.in,velocity.desineuron.in
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# PostgreSQL
|
||||
# -----------------------------------------------------------------------------
|
||||
# Prefer DATABASE_URL in production. VELOCITY_DB_* is retained for services and
|
||||
# seed scripts that construct asyncpg pools from discrete credentials.
|
||||
DATABASE_URL=
|
||||
VELOCITY_DB_HOST=
|
||||
VELOCITY_DB_PORT=5432
|
||||
VELOCITY_DB_NAME=
|
||||
VELOCITY_DB_USER=
|
||||
VELOCITY_DB_PASSWORD=
|
||||
VELOCITY_DB_SSLMODE=require
|
||||
|
||||
# Optional read-only Oracle database credentials for natural-language DB agent.
|
||||
ORACLE_READ_DATABASE_URL=
|
||||
VELOCITY_DB_READ_HOST=
|
||||
VELOCITY_DB_READ_PORT=5432
|
||||
VELOCITY_DB_READ_NAME=
|
||||
VELOCITY_DB_READ_USER=
|
||||
VELOCITY_DB_READ_PASSWORD=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Auth / JWT / Sessions
|
||||
# -----------------------------------------------------------------------------
|
||||
VELOCITY_JWT_SECRET=
|
||||
SECRET_KEY=
|
||||
VELOCITY_PASSWORD_RECOVERY_MINUTES=30
|
||||
# Set to true only in a sealed internal test environment; never on public prod.
|
||||
VELOCITY_AUTH_RETURN_RECOVERY_TOKEN=false
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Enterprise SSO: OAuth / OIDC / SAML
|
||||
# -----------------------------------------------------------------------------
|
||||
# Comma-separated provider IDs exposed to the iPad Settings screen.
|
||||
# Example: VELOCITY_SSO_PROVIDERS=azure_ad,okta
|
||||
VELOCITY_SSO_PROVIDERS=
|
||||
VELOCITY_DEFAULT_SSO_PROVIDER=
|
||||
|
||||
# OAuth/OIDC provider: Azure AD.
|
||||
VELOCITY_SSO_AZURE_AD_TYPE=oauth
|
||||
VELOCITY_SSO_AZURE_AD_NAME=Azure AD
|
||||
VELOCITY_SSO_AZURE_AD_ISSUER=
|
||||
VELOCITY_SSO_AZURE_AD_METADATA_URL=
|
||||
VELOCITY_SSO_AZURE_AD_AUTH_URL=
|
||||
VELOCITY_SSO_AZURE_AD_TOKEN_URL=
|
||||
VELOCITY_SSO_AZURE_AD_CLIENT_ID=
|
||||
VELOCITY_SSO_AZURE_AD_CLIENT_SECRET=
|
||||
VELOCITY_SSO_AZURE_AD_REDIRECT_URI=https://api.desineuron.in/api/auth/sso/azure_ad/callback
|
||||
|
||||
# OAuth/OIDC provider: Okta.
|
||||
VELOCITY_SSO_OKTA_TYPE=oauth
|
||||
VELOCITY_SSO_OKTA_NAME=Okta
|
||||
VELOCITY_SSO_OKTA_ISSUER=
|
||||
VELOCITY_SSO_OKTA_METADATA_URL=
|
||||
VELOCITY_SSO_OKTA_AUTH_URL=
|
||||
VELOCITY_SSO_OKTA_TOKEN_URL=
|
||||
VELOCITY_SSO_OKTA_CLIENT_ID=
|
||||
VELOCITY_SSO_OKTA_CLIENT_SECRET=
|
||||
VELOCITY_SSO_OKTA_REDIRECT_URI=https://api.desineuron.in/api/auth/sso/okta/callback
|
||||
|
||||
# SAML provider values for enterprise tenants that require SAML.
|
||||
VELOCITY_SAML_ENTITY_ID=
|
||||
VELOCITY_SAML_SSO_URL=
|
||||
VELOCITY_SAML_CERTIFICATE_PEM=
|
||||
VELOCITY_SAML_PRIVATE_KEY_PEM=
|
||||
VELOCITY_SAML_ASSERTION_CONSUMER_SERVICE_URL=https://api.desineuron.in/api/auth/saml/acs
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# MDM / Managed App Configuration
|
||||
# -----------------------------------------------------------------------------
|
||||
VELOCITY_MDM_REQUIRED=true
|
||||
VELOCITY_MDM_ORG_NAME=
|
||||
VELOCITY_MDM_SUPPORT_EMAIL=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Communications: WAHA / Evolution / Meta WhatsApp
|
||||
# -----------------------------------------------------------------------------
|
||||
# COMMS_PROVIDER valid values: waha, evolution, mock.
|
||||
COMMS_PROVIDER=waha
|
||||
COMMS_PROVIDER_BASE_URL=
|
||||
COMMS_PROVIDER_API_KEY=
|
||||
COMMS_INSTANCE_ID=
|
||||
COMMS_DEFAULT_COUNTRY_CODE=91
|
||||
COMMS_WEBHOOK_SECRET=
|
||||
COMMS_MEDIA_STORAGE_DIR=/opt/dlami/nvme/assets/comms
|
||||
|
||||
# WAHA-specific values, if production uses WAHA directly.
|
||||
WAHA_BASE_URL=
|
||||
WAHA_API_KEY=
|
||||
WAHA_SESSION=velocity-production
|
||||
WAHA_WEBHOOK_SECRET=
|
||||
WAHA_WEBHOOK_CALLBACK_URL=https://api.desineuron.in/api/comms/webhooks/waha
|
||||
|
||||
# Evolution API-specific values, if production uses Evolution.
|
||||
EVOLUTION_BASE_URL=
|
||||
EVOLUTION_API_KEY=
|
||||
EVOLUTION_INSTANCE_ID=
|
||||
EVOLUTION_WEBHOOK_SECRET=
|
||||
EVOLUTION_WEBHOOK_CALLBACK_URL=https://api.desineuron.in/api/comms/webhooks/evolution
|
||||
|
||||
# Meta Graph / WhatsApp Cloud API values.
|
||||
META_ACCESS_TOKEN=
|
||||
META_APP_ID=
|
||||
META_APP_SECRET=
|
||||
META_BUSINESS_ID=
|
||||
META_AD_ACCOUNT_ID=
|
||||
META_PAGE_ACCESS_TOKEN=
|
||||
META_PAGE_ID=
|
||||
META_INSTAGRAM_BUSINESS_ID=
|
||||
META_PHONE_NUMBER_ID=
|
||||
META_WHATSAPP_BUSINESS_ACCOUNT_ID=
|
||||
META_WEBHOOK_VERIFY_TOKEN=
|
||||
META_API_VERSION=v21.0
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Communications Transcription Providers
|
||||
# -----------------------------------------------------------------------------
|
||||
# COMMS_TRANSCRIPTION_PROVIDER valid values: openai, deepgram, http, none.
|
||||
COMMS_TRANSCRIPTION_PROVIDER=openai
|
||||
COMMS_TRANSCRIPTION_LANGUAGE=en
|
||||
OPENAI_API_KEY=
|
||||
COMMS_OPENAI_TRANSCRIPTION_MODEL=whisper-1
|
||||
DEEPGRAM_API_KEY=
|
||||
COMMS_DEEPGRAM_MODEL=nova-2
|
||||
COMMS_TRANSCRIPTION_ENDPOINT=
|
||||
COMMS_TRANSCRIPTION_ENDPOINT_TOKEN=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Media Storage / AWS S3
|
||||
# -----------------------------------------------------------------------------
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_SESSION_TOKEN=
|
||||
AWS_REGION=ap-south-1
|
||||
AWS_S3_BUCKET=
|
||||
AWS_S3_PUBLIC_BASE_URL=
|
||||
AWS_S3_MEDIA_PREFIX=velocity-production
|
||||
VELOCITY_ASSET_DIR=/opt/dlami/nvme/assets
|
||||
VELOCITY_VIDEO_DIR=/opt/dlami/nvme/assets/videos
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Dream Weaver / ComfyUI / GPU Gateway
|
||||
# -----------------------------------------------------------------------------
|
||||
COMFY_BASE_URL=http://127.0.0.1:8188
|
||||
DREAM_WEAVER_GATEWAY_URL=https://dreamweaver.desineuron.in
|
||||
DREAM_WEAVER_API_KEY=
|
||||
COMFY_CHECKPOINT_NAME=
|
||||
COMFY_WORKFLOW_DIR=/opt/dlami/nvme/velocity/comfy_workflows
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# LLM / NemoClaw Runtime
|
||||
# -----------------------------------------------------------------------------
|
||||
LLM_BASE_URL=https://llm.desineuron.in
|
||||
SGLANG_BASE_URL=https://llm.desineuron.in
|
||||
SGLANG_CHAT_URL=https://llm.desineuron.in/v1/chat/completions
|
||||
SGLANG_MODELS_URL=https://llm.desineuron.in/v1/models
|
||||
SGLANG_MODEL=qwen3.6:35b-a3b
|
||||
SGLANG_API_TOKEN=
|
||||
RUNTIME_LLM_TIMEOUT_S=90.0
|
||||
RUNTIME_LLM_BATCH_CONCURRENCY=2
|
||||
|
||||
NEMOCLAW_BASE_URL=https://llm.desineuron.in
|
||||
NEMOCLAW_CHAT_URL=https://llm.desineuron.in/v1/chat/completions
|
||||
NEMOCLAW_MODEL=qwen3.6:35b-a3b
|
||||
NEMOCLAW_API_TOKEN=
|
||||
NEMOCLAW_WEBHOOK_SECRET=
|
||||
NEMOCLAW_PROMPT_DIR=/opt/dlami/nvme/nemoclaw/prompts
|
||||
NEMOCLAW_TIMEOUT_S=45.0
|
||||
NEMOCLAW_TEMPERATURE=0.2
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Oracle / Sentinel Runtime
|
||||
# -----------------------------------------------------------------------------
|
||||
ORACLE_DEFAULT_TENANT_ID=tenant_velocity
|
||||
ORACLE_DEFAULT_TIMEZONE=Asia/Dubai
|
||||
ORACLE_DEFAULT_LOCALE=en-AE
|
||||
ORACLE_POLICY_PROFILE_ID=policy_sales_director_standard_v4
|
||||
ORACLE_DEFAULT_PAGE_TITLE=Oracle Main Canvas
|
||||
ORACLE_ALLOW_IN_MEMORY_FALLBACK=false
|
||||
SENTINEL_PERCEPTION_INTERVAL_SECONDS=3
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Legacy / Adjacent Integrations
|
||||
# -----------------------------------------------------------------------------
|
||||
# Supabase is retained only for legacy Catalyst CRM/marketing surfaces.
|
||||
SUPABASE_URL=
|
||||
SUPABASE_ANON_KEY=
|
||||
SUPABASE_SERVICE_ROLE_KEY=
|
||||
|
||||
# Ad-network integrations for Catalyst surfaces.
|
||||
GOOGLE_ADS_DEVELOPER_TOKEN=
|
||||
GOOGLE_ADS_CLIENT_ID=
|
||||
GOOGLE_ADS_CLIENT_SECRET=
|
||||
GOOGLE_ADS_REFRESH_TOKEN=
|
||||
GOOGLE_ADS_CUSTOMER_ID=
|
||||
LINKEDIN_ACCESS_TOKEN=
|
||||
LINKEDIN_ORG_ID=
|
||||
TWITTER_BEARER_TOKEN=
|
||||
BRAVE_API_KEY=
|
||||
|
||||
# Colony orchestration service. Required for /api/colony mission dispatch.
|
||||
COLONY_SERVICE_URL=
|
||||
COLONY_TIMEOUT_SECONDS=30
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Observability / Alerts
|
||||
# -----------------------------------------------------------------------------
|
||||
SENTRY_DSN=
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=
|
||||
SLACK_WEBHOOK_URL=
|
||||
PAGERDUTY_ROUTING_KEY=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Fastlane / Apple Release Automation
|
||||
# -----------------------------------------------------------------------------
|
||||
# These are consumed from the operator Mac when running fastlane, not by the
|
||||
# backend service. They are documented here so release secrets are tracked.
|
||||
FASTLANE_APPLE_ID=
|
||||
FASTLANE_TEAM_ID=
|
||||
FASTLANE_ITC_TEAM_ID=
|
||||
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD=
|
||||
FASTLANE_FORCE_CERT=0
|
||||
FASTLANE_FORCE_PROFILE=0
|
||||
FASTLANE_SKIP_WAITING=true
|
||||
FASTLANE_DISTRIBUTE_EXTERNAL=0
|
||||
FASTLANE_NOTIFY_EXTERNAL_TESTERS=0
|
||||
FASTLANE_CHANGELOG=
|
||||
APP_STORE_CONNECT_API_KEY_KEY_ID=
|
||||
APP_STORE_CONNECT_API_KEY_ISSUER_ID=
|
||||
APP_STORE_CONNECT_API_KEY_KEY=
|
||||
@@ -36,6 +36,7 @@ from backend.auth.dependencies import get_current_user
|
||||
logger = logging.getLogger("velocity.admin_surface")
|
||||
|
||||
router = APIRouter()
|
||||
dashboard_router = APIRouter()
|
||||
|
||||
# ── RBAC guard ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -61,6 +62,128 @@ def _pool(request: Request):
|
||||
return pool
|
||||
|
||||
|
||||
class OfflineReplayAuditRecord(BaseModel):
|
||||
id: str
|
||||
kind: str
|
||||
operation: str
|
||||
targetId: str | None = None
|
||||
queuedAt: str
|
||||
attemptCount: int
|
||||
lastAttemptAt: str | None = None
|
||||
lastError: str | None = None
|
||||
|
||||
|
||||
class OfflineReplayAuditRequest(BaseModel):
|
||||
records: list[OfflineReplayAuditRecord] = Field(default_factory=list)
|
||||
pendingCount: int
|
||||
|
||||
|
||||
@dashboard_router.get("/metrics", summary="Canonical dashboard metrics for WebOS and iPad parity")
|
||||
async def get_dashboard_metrics(
|
||||
request: Request,
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
pool = _pool(request)
|
||||
tenant_id = user.tenant_id or "tenant_velocity"
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM crm_leads WHERE tenant_id = $1)::int AS lead_count,
|
||||
(
|
||||
SELECT COUNT(DISTINCT p.person_id)::int
|
||||
FROM crm_people p
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT current_value
|
||||
FROM intel_qd_scores q
|
||||
WHERE q.person_id = p.person_id
|
||||
ORDER BY q.computed_at DESC
|
||||
LIMIT 1
|
||||
) q ON TRUE
|
||||
WHERE p.tenant_id = $1
|
||||
AND (
|
||||
COALESCE(p.buyer_type, '') ILIKE '%whale%'
|
||||
OR COALESCE(q.current_value, 0) >= 0.90
|
||||
)
|
||||
) AS whale_lead_count,
|
||||
(SELECT COUNT(*) FROM inventory_properties WHERE tenant_id = $1 AND status <> 'archived')::int AS property_count,
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM user_calendar_events
|
||||
WHERE tenant_id = $1
|
||||
AND owner_user_id = $2
|
||||
AND status NOT IN ('cancelled', 'done')
|
||||
AND start_at >= date_trunc('day', NOW())
|
||||
AND start_at < date_trunc('day', NOW()) + INTERVAL '1 day'
|
||||
)::int AS today_calendar_count,
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM intel_reminders
|
||||
WHERE COALESCE(tenant_id, $1) = $1
|
||||
AND status IN ('pending', 'open', 'scheduled', 'snoozed', 'confirmed')
|
||||
)::int AS pending_task_count,
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM intel_reminders
|
||||
WHERE COALESCE(tenant_id, $1) = $1
|
||||
AND status IN ('pending', 'open', 'scheduled', 'snoozed', 'confirmed')
|
||||
AND priority IN ('urgent', 'high')
|
||||
)::int AS urgent_task_count,
|
||||
(SELECT COUNT(*) FROM insight_recommendations WHERE tenant_id = $1 AND status = 'pending')::int AS pending_insights,
|
||||
(SELECT COUNT(*) FROM edge_transcription_jobs WHERE tenant_id = $1 AND status = 'pending')::int AS pending_transcriptions
|
||||
""",
|
||||
tenant_id,
|
||||
user.user_id,
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"leadCount": row["lead_count"],
|
||||
"whaleLeadCount": row["whale_lead_count"],
|
||||
"propertyCount": row["property_count"],
|
||||
"todayCalendarCount": row["today_calendar_count"],
|
||||
"pendingTaskCount": row["pending_task_count"],
|
||||
"urgentTaskCount": row["urgent_task_count"],
|
||||
"pendingInsights": row["pending_insights"],
|
||||
"pendingTranscriptions": row["pending_transcriptions"],
|
||||
},
|
||||
"meta": {"generatedAt": datetime.now(timezone.utc).isoformat()},
|
||||
}
|
||||
|
||||
|
||||
@dashboard_router.post("/offline-replay/audit", summary="Publish native offline replay queue observability")
|
||||
async def publish_offline_replay_audit(
|
||||
body: OfflineReplayAuditRequest,
|
||||
request: Request,
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS mobile_offline_replay_audits (
|
||||
audit_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
pending_count INT NOT NULL,
|
||||
records JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""
|
||||
)
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO mobile_offline_replay_audits (tenant_id, user_id, pending_count, records)
|
||||
VALUES ($1, $2, $3, $4::jsonb)
|
||||
""",
|
||||
user.tenant_id,
|
||||
user.user_id,
|
||||
body.pendingCount,
|
||||
json.dumps([record.model_dump() for record in body.records]),
|
||||
)
|
||||
return {"status": "ok", "pendingCount": body.pendingCount}
|
||||
|
||||
|
||||
# ── Pydantic Models ───────────────────────────────────────────────────────────
|
||||
|
||||
VALID_ACTION_TYPES = {
|
||||
|
||||
@@ -10,6 +10,8 @@ Routes:
|
||||
POST /api/catalyst/auth/meta — OAuth token acquisition
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
import hashlib
|
||||
@@ -17,9 +19,11 @@ import logging
|
||||
from typing import Any
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request, status
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.auth.dependencies import UserPrincipal, get_current_user
|
||||
from backend.services.ad_network_service import (
|
||||
AdInsight,
|
||||
BidStrategyUpdate,
|
||||
@@ -27,6 +31,17 @@ from backend.services.ad_network_service import (
|
||||
Platform,
|
||||
ad_network_service,
|
||||
)
|
||||
from backend.services.social_posting import (
|
||||
PostRequest,
|
||||
PostStatus,
|
||||
SocialPlatform,
|
||||
SocialPostingConfigurationError,
|
||||
SocialPostingError,
|
||||
get_post,
|
||||
list_posts,
|
||||
publish_content,
|
||||
publish_due_scheduled,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -91,6 +106,13 @@ def _ok(data: Any, meta: dict | None = None) -> dict:
|
||||
return {"status": "ok", "data": data, "meta": meta or {}}
|
||||
|
||||
|
||||
def _get_db_pool(request: Request) -> Any:
|
||||
pool = getattr(request.app.state, "db_pool", None)
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="Database unavailable.")
|
||||
return pool
|
||||
|
||||
|
||||
def _sha256_hash(value: str) -> str:
|
||||
"""SHA-256 hash an email for Meta's hashed audience upload."""
|
||||
return hashlib.sha256(value.strip().lower().encode()).hexdigest()
|
||||
@@ -510,3 +532,91 @@ async def meta_oauth(payload: MetaAuthRequest) -> dict:
|
||||
"token_type": token_data.get("token_type", "bearer"),
|
||||
"expires_in": token_data.get("expires_in"),
|
||||
})
|
||||
|
||||
|
||||
# ── 6. Social publishing ─────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/publish", status_code=status.HTTP_201_CREATED, summary="Publish or schedule content to social channels")
|
||||
async def api_publish_content(
|
||||
payload: PostRequest,
|
||||
request: Request,
|
||||
user: UserPrincipal = Depends(get_current_user),
|
||||
) -> dict:
|
||||
try:
|
||||
result = await publish_content(
|
||||
pool=_get_db_pool(request),
|
||||
tenant_id=user.tenant_id,
|
||||
actor_id=user.user_id,
|
||||
payload=payload,
|
||||
)
|
||||
except SocialPostingConfigurationError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=f"Invalid schedule_time: {exc}") from exc
|
||||
except (SocialPostingError, httpx.HTTPError) as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
return _ok(result)
|
||||
|
||||
|
||||
@router.get("/posts", summary="List tenant-scoped social posts")
|
||||
async def api_list_social_posts(
|
||||
request: Request,
|
||||
platform: SocialPlatform | None = Query(default=None),
|
||||
post_status: PostStatus | None = Query(default=None, alias="status"),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
user: UserPrincipal = Depends(get_current_user),
|
||||
) -> dict:
|
||||
posts = await list_posts(
|
||||
pool=_get_db_pool(request),
|
||||
tenant_id=user.tenant_id,
|
||||
platform=platform,
|
||||
status=post_status,
|
||||
limit=limit,
|
||||
)
|
||||
return _ok(posts, meta={"count": len(posts)})
|
||||
|
||||
|
||||
@router.get("/posts/{post_id}", summary="Get a tenant-scoped social post")
|
||||
async def api_get_social_post(
|
||||
post_id: str,
|
||||
request: Request,
|
||||
user: UserPrincipal = Depends(get_current_user),
|
||||
) -> dict:
|
||||
post = await get_post(pool=_get_db_pool(request), tenant_id=user.tenant_id, post_id=post_id)
|
||||
if post is None:
|
||||
raise HTTPException(status_code=404, detail=f"Social post '{post_id}' not found.")
|
||||
return _ok(post)
|
||||
|
||||
|
||||
@router.get("/scheduled", summary="List scheduled social posts for the authenticated tenant")
|
||||
async def api_scheduled_posts(
|
||||
request: Request,
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
user: UserPrincipal = Depends(get_current_user),
|
||||
) -> dict:
|
||||
posts = await list_posts(
|
||||
pool=_get_db_pool(request),
|
||||
tenant_id=user.tenant_id,
|
||||
status=PostStatus.SCHEDULED,
|
||||
limit=limit,
|
||||
)
|
||||
return _ok(posts, meta={"count": len(posts)})
|
||||
|
||||
|
||||
@router.post("/scheduled/publish-due", summary="Publish due scheduled social posts for the authenticated tenant")
|
||||
async def api_publish_due_scheduled(
|
||||
request: Request,
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
user: UserPrincipal = Depends(get_current_user),
|
||||
) -> dict:
|
||||
try:
|
||||
result = await publish_due_scheduled(
|
||||
pool=_get_db_pool(request),
|
||||
tenant_id=user.tenant_id,
|
||||
limit=limit,
|
||||
)
|
||||
except SocialPostingConfigurationError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
except (SocialPostingError, httpx.HTTPError) as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
return _ok(result)
|
||||
|
||||
251
backend/api/routes_colony.py
Normal file
251
backend/api/routes_colony.py
Normal file
@@ -0,0 +1,251 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any, Literal
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.auth.dependencies import UserPrincipal, get_current_user
|
||||
from backend.services.colony_gateway import ColonyConfigurationError, ColonyGateway, ColonyGatewayError
|
||||
from backend.services.colony_repository import ColonyRepository
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
MissionType = Literal["oracle_advisory", "crm_lead_intelligence", "catalyst_strategy_brief"]
|
||||
RiskLevel = Literal["low", "medium", "high"]
|
||||
SensitivityClass = Literal["public", "internal", "confidential"]
|
||||
|
||||
|
||||
class MissionCreateRequest(BaseModel):
|
||||
mission_type: MissionType
|
||||
user_goal: str = Field(..., min_length=1, max_length=2000)
|
||||
normalized_goal: str | None = Field(default=None, max_length=2000)
|
||||
origin_surface: str = Field(default="api", min_length=1, max_length=128)
|
||||
actor_role: str | None = Field(default=None, max_length=128)
|
||||
risk_level: RiskLevel = "low"
|
||||
sensitivity_class: SensitivityClass = "internal"
|
||||
time_budget_ms: int = Field(default=30000, gt=0, le=300000)
|
||||
token_budget: int = Field(default=4096, gt=0, le=200000)
|
||||
context_refs: dict[str, Any] = Field(default_factory=dict)
|
||||
requested_outputs: list[str] = Field(default_factory=list)
|
||||
payload: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ApprovalRequest(BaseModel):
|
||||
reason: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
|
||||
def _get_repo(request: Request) -> ColonyRepository:
|
||||
pool = getattr(request.app.state, "db_pool", None)
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="Database unavailable.")
|
||||
return ColonyRepository(pool)
|
||||
|
||||
|
||||
def _serialize_mission(row: dict[str, Any], dispatch: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
data = {
|
||||
"mission_id": str(row["mission_id"]),
|
||||
"tenant_id": row["tenant_id"],
|
||||
"mission_type": row["mission_type"],
|
||||
"origin_surface": row["origin_surface"],
|
||||
"actor_id": row["actor_id"],
|
||||
"actor_role": row["actor_role"],
|
||||
"risk_level": row["risk_level"],
|
||||
"sensitivity_class": row["sensitivity_class"],
|
||||
"status": row["status"],
|
||||
"review_status": row["review_status"],
|
||||
"time_budget_ms": row["time_budget_ms"],
|
||||
"token_budget": row["token_budget"],
|
||||
"user_goal": row["user_goal"],
|
||||
"normalized_goal": row["normalized_goal"],
|
||||
"context_refs": row["context_refs"] or {},
|
||||
"requested_outputs": row["requested_outputs"] or [],
|
||||
"payload": row["payload"] or {},
|
||||
"created_at": row["created_at"].isoformat() if row.get("created_at") else None,
|
||||
"updated_at": row["updated_at"].isoformat() if row.get("updated_at") else None,
|
||||
"completed_at": row["completed_at"].isoformat() if row.get("completed_at") else None,
|
||||
}
|
||||
if dispatch is not None:
|
||||
data["dispatch"] = dispatch
|
||||
return data
|
||||
|
||||
|
||||
@router.post("/missions", status_code=status.HTTP_201_CREATED, summary="Create and dispatch a colony mission")
|
||||
async def create_mission(
|
||||
body: MissionCreateRequest,
|
||||
request: Request,
|
||||
user: UserPrincipal = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
repo = _get_repo(request)
|
||||
try:
|
||||
gateway = ColonyGateway()
|
||||
except ColonyConfigurationError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
|
||||
mission_id = str(uuid.uuid4())
|
||||
mission = {
|
||||
"mission_id": mission_id,
|
||||
"mission_type": body.mission_type,
|
||||
"origin_surface": body.origin_surface,
|
||||
"tenant_id": user.tenant_id,
|
||||
"actor_id": user.user_id,
|
||||
"actor_role": body.actor_role or user.role,
|
||||
"risk_level": body.risk_level,
|
||||
"sensitivity_class": body.sensitivity_class,
|
||||
"time_budget_ms": body.time_budget_ms,
|
||||
"token_budget": body.token_budget,
|
||||
"user_goal": body.user_goal,
|
||||
"normalized_goal": body.normalized_goal or body.user_goal,
|
||||
"context_refs": body.context_refs,
|
||||
"requested_outputs": body.requested_outputs,
|
||||
"payload": body.payload,
|
||||
}
|
||||
row = await repo.create_mission(mission)
|
||||
await repo.log_event(
|
||||
mission_id=mission_id,
|
||||
tenant_id=user.tenant_id,
|
||||
event_type="mission_created",
|
||||
actor=user.user_id,
|
||||
detail={"mission_type": body.mission_type},
|
||||
)
|
||||
|
||||
try:
|
||||
dispatch = await gateway.dispatch_mission(mission)
|
||||
except ColonyGatewayError as exc:
|
||||
failed = await repo.update_status(mission_id, user.tenant_id, "dispatch_failed")
|
||||
await repo.log_event(
|
||||
mission_id=mission_id,
|
||||
tenant_id=user.tenant_id,
|
||||
event_type="mission_dispatch_failed",
|
||||
actor=user.user_id,
|
||||
detail={"error": str(exc)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={
|
||||
"message": str(exc),
|
||||
"mission": _serialize_mission(failed or row),
|
||||
},
|
||||
) from exc
|
||||
|
||||
queued = await repo.update_status(mission_id, user.tenant_id, "queued")
|
||||
await repo.log_event(
|
||||
mission_id=mission_id,
|
||||
tenant_id=user.tenant_id,
|
||||
event_type="mission_dispatched",
|
||||
actor=user.user_id,
|
||||
detail={"dispatch": dispatch},
|
||||
)
|
||||
return {"status": "ok", "data": _serialize_mission(queued or row, dispatch=dispatch)}
|
||||
|
||||
|
||||
@router.get("/missions", summary="List colony missions for the authenticated tenant")
|
||||
async def list_missions(
|
||||
request: Request,
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
user: UserPrincipal = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
rows = await _get_repo(request).list_missions(user.tenant_id, limit=limit, offset=offset)
|
||||
return {"status": "ok", "data": [_serialize_mission(row) for row in rows], "meta": {"count": len(rows)}}
|
||||
|
||||
|
||||
@router.get("/missions/{mission_id}", summary="Get a colony mission")
|
||||
async def get_mission(
|
||||
mission_id: str,
|
||||
request: Request,
|
||||
user: UserPrincipal = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
row = await _get_repo(request).get_mission(mission_id, user.tenant_id)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail=f"Mission '{mission_id}' not found.")
|
||||
return {"status": "ok", "data": _serialize_mission(row)}
|
||||
|
||||
|
||||
@router.get("/missions/{mission_id}/artifacts", summary="Get mission tasks, results, and writeback proposals")
|
||||
async def get_artifacts(
|
||||
mission_id: str,
|
||||
request: Request,
|
||||
user: UserPrincipal = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
artifacts = await _get_repo(request).artifacts(mission_id, user.tenant_id)
|
||||
if not artifacts:
|
||||
raise HTTPException(status_code=404, detail=f"Mission '{mission_id}' not found.")
|
||||
return {"status": "ok", "data": artifacts}
|
||||
|
||||
|
||||
@router.post("/missions/{mission_id}/approve", summary="Approve all pending writeback proposals for a mission")
|
||||
async def approve_writebacks(
|
||||
mission_id: str,
|
||||
body: ApprovalRequest,
|
||||
request: Request,
|
||||
user: UserPrincipal = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
del body
|
||||
repo = _get_repo(request)
|
||||
if await repo.get_mission(mission_id, user.tenant_id) is None:
|
||||
raise HTTPException(status_code=404, detail=f"Mission '{mission_id}' not found.")
|
||||
count = await repo.approve_pending_writebacks(mission_id, user.tenant_id, user.user_id)
|
||||
if count == 0:
|
||||
raise HTTPException(status_code=404, detail="No pending writeback proposals.")
|
||||
await repo.log_event(
|
||||
mission_id=mission_id,
|
||||
tenant_id=user.tenant_id,
|
||||
event_type="writeback_approved",
|
||||
actor=user.user_id,
|
||||
detail={"approved": count},
|
||||
)
|
||||
return {"status": "ok", "data": {"approved": count}}
|
||||
|
||||
|
||||
@router.post("/missions/{mission_id}/reject", summary="Reject all pending writeback proposals for a mission")
|
||||
async def reject_writebacks(
|
||||
mission_id: str,
|
||||
body: ApprovalRequest,
|
||||
request: Request,
|
||||
user: UserPrincipal = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
repo = _get_repo(request)
|
||||
if await repo.get_mission(mission_id, user.tenant_id) is None:
|
||||
raise HTTPException(status_code=404, detail=f"Mission '{mission_id}' not found.")
|
||||
count = await repo.reject_pending_writebacks(
|
||||
mission_id,
|
||||
user.tenant_id,
|
||||
user.user_id,
|
||||
body.reason or "Rejected by operator.",
|
||||
)
|
||||
if count == 0:
|
||||
raise HTTPException(status_code=404, detail="No pending writeback proposals.")
|
||||
await repo.log_event(
|
||||
mission_id=mission_id,
|
||||
tenant_id=user.tenant_id,
|
||||
event_type="writeback_rejected",
|
||||
actor=user.user_id,
|
||||
detail={"rejected": count, "reason": body.reason},
|
||||
)
|
||||
return {"status": "ok", "data": {"rejected": count}}
|
||||
|
||||
|
||||
@router.get("/health", summary="Check colony root persistence and orchestrator connectivity")
|
||||
async def colony_health(
|
||||
request: Request,
|
||||
user: UserPrincipal = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
repo = _get_repo(request)
|
||||
try:
|
||||
gateway = ColonyGateway()
|
||||
service = await gateway.health()
|
||||
except (ColonyConfigurationError, ColonyGatewayError, httpx.HTTPError) as exc: # type: ignore[name-defined]
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
rows = await repo.list_missions(user.tenant_id, limit=1, offset=0)
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"tenant_id": user.tenant_id,
|
||||
"root_db": "connected",
|
||||
"orchestrator": service,
|
||||
"has_missions": bool(rows),
|
||||
},
|
||||
}
|
||||
@@ -18,7 +18,7 @@ from pydantic import BaseModel
|
||||
|
||||
from backend.auth.dependencies import UserPrincipal, get_current_user
|
||||
from backend.services.comms_evolution_provider import EvolutionProvider
|
||||
from backend.services.comms_ingest import ingest_inbound_message
|
||||
from backend.services.comms_ingest import TranscriptionError, ingest_inbound_message, transcribe_recording as run_transcription
|
||||
from backend.services.comms_provider import MockProvider
|
||||
from backend.services.comms_waha_provider import WahaProvider
|
||||
|
||||
@@ -46,6 +46,8 @@ class NoteBody(BaseModel):
|
||||
class TaskBody(BaseModel):
|
||||
title: str
|
||||
dueAt: str | None = None
|
||||
notes: str | None = None
|
||||
priority: str = "normal"
|
||||
|
||||
|
||||
class SettingsPatch(BaseModel):
|
||||
@@ -158,6 +160,37 @@ def _record_value(row: Any, key: str, default: Any = None) -> Any:
|
||||
return default
|
||||
|
||||
|
||||
def _optional_datetime(value: str | None) -> datetime | None:
|
||||
if not value or not value.strip():
|
||||
return None
|
||||
normalized = value.strip().replace("Z", "+00:00")
|
||||
try:
|
||||
return datetime.fromisoformat(normalized)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail="dueAt must be an ISO-8601 timestamp.") from exc
|
||||
|
||||
|
||||
async def _thread_context(conn, thread_id: str, tenant_id: str):
|
||||
thread = await conn.fetchrow("SELECT * FROM comms_threads WHERE thread_id = $1::uuid", thread_id)
|
||||
if not thread:
|
||||
raise HTTPException(status_code=404, detail="Thread not found")
|
||||
lead_id = None
|
||||
if thread["person_id"]:
|
||||
lead_id = await conn.fetchval(
|
||||
"""
|
||||
SELECT lead_id
|
||||
FROM crm_leads
|
||||
WHERE person_id = $1::uuid
|
||||
AND tenant_id = $2
|
||||
ORDER BY updated_at DESC NULLS LAST, created_at DESC NULLS LAST
|
||||
LIMIT 1
|
||||
""",
|
||||
thread["person_id"],
|
||||
tenant_id,
|
||||
)
|
||||
return thread, lead_id
|
||||
|
||||
|
||||
async def _ensure_schema(pool) -> None:
|
||||
global _SCHEMA_READY
|
||||
if _SCHEMA_READY:
|
||||
@@ -182,6 +215,19 @@ async def _ensure_schema(pool) -> None:
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
ALTER TABLE comms_threads ADD COLUMN IF NOT EXISTS provider TEXT NOT NULL DEFAULT 'mock';
|
||||
ALTER TABLE comms_threads ADD COLUMN IF NOT EXISTS external_thread_id TEXT;
|
||||
ALTER TABLE comms_threads ADD COLUMN IF NOT EXISTS person_id UUID NULL REFERENCES crm_people(person_id) ON DELETE SET NULL;
|
||||
ALTER TABLE comms_threads ADD COLUMN IF NOT EXISTS phone_e164 TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE comms_threads ADD COLUMN IF NOT EXISTS display_name TEXT;
|
||||
ALTER TABLE comms_threads ADD COLUMN IF NOT EXISTS channel TEXT NOT NULL DEFAULT 'whatsapp';
|
||||
ALTER TABLE comms_threads ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open';
|
||||
ALTER TABLE comms_threads ADD COLUMN IF NOT EXISTS assigned_user_id UUID NULL;
|
||||
ALTER TABLE comms_threads ADD COLUMN IF NOT EXISTS last_message_at TIMESTAMPTZ;
|
||||
ALTER TABLE comms_threads ADD COLUMN IF NOT EXISTS unread_count INT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE comms_threads ADD COLUMN IF NOT EXISTS metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
ALTER TABLE comms_threads ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
|
||||
ALTER TABLE comms_threads ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
|
||||
CREATE INDEX IF NOT EXISTS idx_comms_threads_phone_provider ON comms_threads(provider, 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);
|
||||
@@ -203,6 +249,19 @@ async def _ensure_schema(pool) -> None:
|
||||
raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
ALTER TABLE comms_messages ADD COLUMN IF NOT EXISTS provider TEXT NOT NULL DEFAULT 'mock';
|
||||
ALTER TABLE comms_messages ADD COLUMN IF NOT EXISTS external_message_id TEXT;
|
||||
ALTER TABLE comms_messages ADD COLUMN IF NOT EXISTS direction TEXT NOT NULL DEFAULT 'system';
|
||||
ALTER TABLE comms_messages ADD COLUMN IF NOT EXISTS message_type TEXT NOT NULL DEFAULT 'text';
|
||||
ALTER TABLE comms_messages ADD COLUMN IF NOT EXISTS body TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE comms_messages ADD COLUMN IF NOT EXISTS media_url TEXT;
|
||||
ALTER TABLE comms_messages ADD COLUMN IF NOT EXISTS media_mime_type TEXT;
|
||||
ALTER TABLE comms_messages ADD COLUMN IF NOT EXISTS delivery_status TEXT NOT NULL DEFAULT 'pending';
|
||||
ALTER TABLE comms_messages ADD COLUMN IF NOT EXISTS sent_at TIMESTAMPTZ;
|
||||
ALTER TABLE comms_messages ADD COLUMN IF NOT EXISTS delivered_at TIMESTAMPTZ;
|
||||
ALTER TABLE comms_messages ADD COLUMN IF NOT EXISTS read_at TIMESTAMPTZ;
|
||||
ALTER TABLE comms_messages ADD COLUMN IF NOT EXISTS raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
ALTER TABLE comms_messages ADD COLUMN IF NOT EXISTS 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;
|
||||
CREATE TABLE IF NOT EXISTS comms_call_logs (
|
||||
@@ -223,6 +282,21 @@ async def _ensure_schema(pool) -> None:
|
||||
raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
ALTER TABLE comms_call_logs ADD COLUMN IF NOT EXISTS thread_id UUID NULL REFERENCES comms_threads(thread_id) ON DELETE SET NULL;
|
||||
ALTER TABLE comms_call_logs ADD COLUMN IF NOT EXISTS person_id UUID NULL REFERENCES crm_people(person_id) ON DELETE SET NULL;
|
||||
ALTER TABLE comms_call_logs ADD COLUMN IF NOT EXISTS provider TEXT NOT NULL DEFAULT 'mock';
|
||||
ALTER TABLE comms_call_logs ADD COLUMN IF NOT EXISTS external_call_id TEXT;
|
||||
ALTER TABLE comms_call_logs ADD COLUMN IF NOT EXISTS phone_e164 TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE comms_call_logs ADD COLUMN IF NOT EXISTS direction TEXT NOT NULL DEFAULT 'inbound';
|
||||
ALTER TABLE comms_call_logs ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'completed';
|
||||
ALTER TABLE comms_call_logs ADD COLUMN IF NOT EXISTS started_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
|
||||
ALTER TABLE comms_call_logs ADD COLUMN IF NOT EXISTS ended_at TIMESTAMPTZ;
|
||||
ALTER TABLE comms_call_logs ADD COLUMN IF NOT EXISTS duration_seconds INT;
|
||||
ALTER TABLE comms_call_logs ADD COLUMN IF NOT EXISTS recording_url TEXT;
|
||||
ALTER TABLE comms_call_logs ADD COLUMN IF NOT EXISTS transcript_id UUID;
|
||||
ALTER TABLE comms_call_logs ADD COLUMN IF NOT EXISTS transcript_text TEXT;
|
||||
ALTER TABLE comms_call_logs ADD COLUMN IF NOT EXISTS raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
ALTER TABLE comms_call_logs ADD COLUMN IF NOT EXISTS 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;
|
||||
CREATE TABLE IF NOT EXISTS comms_settings (
|
||||
@@ -422,6 +496,54 @@ async def list_messages(
|
||||
return {"messages": messages, "thread": await get_thread(thread_id, request)}
|
||||
|
||||
|
||||
@router.get("/threads/{thread_id}/calls")
|
||||
async def list_thread_calls(
|
||||
thread_id: str,
|
||||
request: Request,
|
||||
limit: int = 25,
|
||||
offset: int = 0,
|
||||
_: UserPrincipal = Depends(get_current_user),
|
||||
):
|
||||
pool = await _pool(request)
|
||||
limit = max(1, min(limit, 100))
|
||||
offset = max(0, offset)
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT *
|
||||
FROM comms_call_logs
|
||||
WHERE thread_id = $1::uuid
|
||||
ORDER BY started_at DESC, created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
""",
|
||||
thread_id,
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
calls = [
|
||||
{
|
||||
"callId": str(row["call_id"]),
|
||||
"threadId": str(row["thread_id"]) if row["thread_id"] else None,
|
||||
"personId": str(row["person_id"]) if row["person_id"] else None,
|
||||
"provider": row["provider"],
|
||||
"externalCallId": row["external_call_id"],
|
||||
"phoneE164": row["phone_e164"],
|
||||
"direction": row["direction"],
|
||||
"status": row["status"],
|
||||
"startedAt": row["started_at"].isoformat(),
|
||||
"endedAt": row["ended_at"].isoformat() if row["ended_at"] else None,
|
||||
"durationSeconds": row["duration_seconds"],
|
||||
"recordingUrl": row["recording_url"],
|
||||
"transcriptId": str(row["transcript_id"]) if row["transcript_id"] else None,
|
||||
"transcriptText": row["transcript_text"],
|
||||
"rawPayload": _json_obj(row["raw_payload"]),
|
||||
"createdAt": row["created_at"].isoformat(),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
return {"calls": calls, "thread": await get_thread(thread_id, request)}
|
||||
|
||||
|
||||
@router.post("/threads/{thread_id}/messages")
|
||||
async def send_message(
|
||||
thread_id: str,
|
||||
@@ -465,11 +587,15 @@ async def link_person(
|
||||
thread_id: str,
|
||||
body: LinkPersonBody,
|
||||
request: Request,
|
||||
_: UserPrincipal = Depends(get_current_user),
|
||||
user: UserPrincipal = Depends(get_current_user),
|
||||
):
|
||||
pool = await _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
exists = await conn.fetchval("SELECT EXISTS (SELECT 1 FROM crm_people WHERE person_id = $1::uuid)", body.personId)
|
||||
exists = await conn.fetchval(
|
||||
"SELECT EXISTS (SELECT 1 FROM crm_people WHERE person_id = $1::uuid AND tenant_id = $2)",
|
||||
body.personId,
|
||||
user.tenant_id,
|
||||
)
|
||||
if not exists:
|
||||
raise HTTPException(status_code=404, detail="CRM person not found")
|
||||
updated = await conn.execute(
|
||||
@@ -481,36 +607,127 @@ async def link_person(
|
||||
|
||||
|
||||
@router.post("/threads/{thread_id}/notes")
|
||||
async def add_note(thread_id: str, body: NoteBody, request: Request, _: UserPrincipal = Depends(get_current_user)):
|
||||
async def add_note(thread_id: str, body: NoteBody, request: Request, user: UserPrincipal = Depends(get_current_user)):
|
||||
pool = await _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
msg_id = await conn.fetchval(
|
||||
"""
|
||||
INSERT INTO comms_messages (thread_id, provider, direction, message_type, body, delivery_status)
|
||||
VALUES ($1::uuid, 'system', 'system', 'text', $2, 'delivered')
|
||||
RETURNING message_id
|
||||
""",
|
||||
thread_id,
|
||||
f"Note: {body.content}",
|
||||
)
|
||||
return {"messageId": str(msg_id)}
|
||||
async with conn.transaction():
|
||||
thread, lead_id = await _thread_context(conn, thread_id, user.tenant_id)
|
||||
msg_id = await conn.fetchval(
|
||||
"""
|
||||
INSERT INTO comms_messages (thread_id, provider, direction, message_type, body, delivery_status)
|
||||
VALUES ($1::uuid, 'system', 'system', 'text', $2, 'delivered')
|
||||
RETURNING message_id
|
||||
""",
|
||||
thread_id,
|
||||
f"Note: {body.content}",
|
||||
)
|
||||
interaction_id = None
|
||||
canonical_message_id = None
|
||||
if thread["person_id"]:
|
||||
interaction_id = await conn.fetchval(
|
||||
"""
|
||||
INSERT INTO intel_interactions (
|
||||
interaction_id, tenant_id, person_id, lead_id, channel,
|
||||
interaction_type, happened_at, summary, source_ref, metadata_json
|
||||
) VALUES (
|
||||
gen_random_uuid(), COALESCE($1, 'tenant_velocity'), $2::uuid, $3::uuid, 'whatsapp',
|
||||
'operator_note', NOW(), $4, $5, $6::jsonb
|
||||
)
|
||||
RETURNING interaction_id
|
||||
""",
|
||||
user.tenant_id,
|
||||
thread["person_id"],
|
||||
lead_id,
|
||||
body.content,
|
||||
f"comms:{thread_id}",
|
||||
json.dumps({"source": "comms_thread_note", "thread_id": thread_id, "message_id": str(msg_id)}),
|
||||
)
|
||||
canonical_message_id = await conn.fetchval(
|
||||
"""
|
||||
INSERT INTO intel_messages (
|
||||
message_id, interaction_id, thread_id, sender_role, sender_name,
|
||||
message_text, delivered_at, metadata_json
|
||||
) VALUES (
|
||||
gen_random_uuid(), $1::uuid, $2::uuid, 'operator', 'iPad operator',
|
||||
$3, NOW(), $4::jsonb
|
||||
)
|
||||
RETURNING message_id
|
||||
""",
|
||||
interaction_id,
|
||||
thread_id,
|
||||
body.content,
|
||||
json.dumps({"source": "comms_thread_note"}),
|
||||
)
|
||||
return {
|
||||
"messageId": str(msg_id),
|
||||
"canonicalInteractionId": str(interaction_id) if interaction_id else None,
|
||||
"canonicalMessageId": str(canonical_message_id) if canonical_message_id else None,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/threads/{thread_id}/tasks")
|
||||
async def add_task(thread_id: str, body: TaskBody, request: Request, _: UserPrincipal = Depends(get_current_user)):
|
||||
async def add_task(thread_id: str, body: TaskBody, request: Request, user: UserPrincipal = Depends(get_current_user)):
|
||||
pool = await _pool(request)
|
||||
text = f"Task: {body.title}" + (f" (Due: {body.dueAt})" if body.dueAt else "")
|
||||
async with pool.acquire() as conn:
|
||||
msg_id = await conn.fetchval(
|
||||
"""
|
||||
INSERT INTO comms_messages (thread_id, provider, direction, message_type, body, delivery_status)
|
||||
VALUES ($1::uuid, 'system', 'system', 'text', $2, 'delivered')
|
||||
RETURNING message_id
|
||||
""",
|
||||
thread_id,
|
||||
text,
|
||||
)
|
||||
return {"messageId": str(msg_id)}
|
||||
async with conn.transaction():
|
||||
thread, lead_id = await _thread_context(conn, thread_id, user.tenant_id)
|
||||
msg_id = await conn.fetchval(
|
||||
"""
|
||||
INSERT INTO comms_messages (thread_id, provider, direction, message_type, body, delivery_status)
|
||||
VALUES ($1::uuid, 'system', 'system', 'text', $2, 'delivered')
|
||||
RETURNING message_id
|
||||
""",
|
||||
thread_id,
|
||||
text,
|
||||
)
|
||||
reminder_id = None
|
||||
interaction_id = None
|
||||
if thread["person_id"]:
|
||||
interaction_id = await conn.fetchval(
|
||||
"""
|
||||
INSERT INTO intel_interactions (
|
||||
interaction_id, tenant_id, person_id, lead_id, channel,
|
||||
interaction_type, happened_at, summary, source_ref, metadata_json
|
||||
) VALUES (
|
||||
gen_random_uuid(), COALESCE($1, 'tenant_velocity'), $2::uuid, $3::uuid, 'whatsapp',
|
||||
'next_best_action', NOW(), $4, $5, $6::jsonb
|
||||
)
|
||||
RETURNING interaction_id
|
||||
""",
|
||||
user.tenant_id,
|
||||
thread["person_id"],
|
||||
lead_id,
|
||||
body.title,
|
||||
f"comms:{thread_id}",
|
||||
json.dumps({"source": "comms_thread_task", "thread_id": thread_id, "message_id": str(msg_id)}),
|
||||
)
|
||||
reminder_id = await conn.fetchval(
|
||||
"""
|
||||
INSERT INTO intel_reminders (
|
||||
reminder_id, tenant_id, person_id, lead_id, interaction_id,
|
||||
reminder_type, title, notes, due_at, status, priority,
|
||||
created_by_type, created_at
|
||||
) VALUES (
|
||||
gen_random_uuid(), COALESCE($1, 'tenant_velocity'), $2::uuid, $3::uuid, $4::uuid,
|
||||
'follow_up', $5, $6, $7, 'pending', $8, 'human', NOW()
|
||||
)
|
||||
RETURNING reminder_id
|
||||
""",
|
||||
user.tenant_id,
|
||||
thread["person_id"],
|
||||
lead_id,
|
||||
interaction_id,
|
||||
body.title,
|
||||
body.notes,
|
||||
_optional_datetime(body.dueAt),
|
||||
body.priority,
|
||||
)
|
||||
return {
|
||||
"messageId": str(msg_id),
|
||||
"canonicalInteractionId": str(interaction_id) if interaction_id else None,
|
||||
"canonicalReminderId": str(reminder_id) if reminder_id else None,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/webhooks/{provider}")
|
||||
@@ -572,17 +789,53 @@ async def test_provider(request: Request, _: UserPrincipal = Depends(get_current
|
||||
@router.post("/recordings/transcribe")
|
||||
async def transcribe_recording(body: TranscribeBody, request: Request, _: UserPrincipal = Depends(get_current_user)):
|
||||
pool = await _pool(request)
|
||||
config = await _load_config(pool)
|
||||
configured_provider = str(config.get("transcription_provider") or "").strip().lower()
|
||||
env_provider = os.getenv("COMMS_TRANSCRIPTION_PROVIDER", "none").strip().lower()
|
||||
provider = env_provider if configured_provider in {"", "none", "disabled"} else configured_provider
|
||||
recording_url = body.recordingUrl
|
||||
if body.callId and not recording_url:
|
||||
async with pool.acquire() as conn:
|
||||
recording_url = await conn.fetchval(
|
||||
"SELECT recording_url FROM comms_call_logs WHERE call_id = $1::uuid",
|
||||
body.callId,
|
||||
)
|
||||
if not recording_url:
|
||||
raise HTTPException(status_code=422, detail="recordingUrl is required when callId has no stored recording_url.")
|
||||
|
||||
try:
|
||||
result = await run_transcription(recording_url, provider=provider)
|
||||
except TranscriptionError as exc:
|
||||
if body.callId:
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"UPDATE comms_call_logs SET transcript_text = $1 WHERE call_id = $2::uuid",
|
||||
f"Transcription failed: {exc}",
|
||||
body.callId,
|
||||
)
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
|
||||
if body.callId:
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"UPDATE comms_call_logs SET transcript_text = $1 WHERE call_id = $2::uuid",
|
||||
"Transcription pending. Configure COMMS_TRANSCRIPTION_PROVIDER to enable processing.",
|
||||
"""
|
||||
UPDATE comms_call_logs
|
||||
SET transcript_text = $1,
|
||||
raw_payload = COALESCE(raw_payload, '{}'::jsonb) || $2::jsonb
|
||||
WHERE call_id = $3::uuid
|
||||
""",
|
||||
result["text"],
|
||||
json.dumps({"transcription": {"provider": result["provider"], "language": result["language"]}}),
|
||||
body.callId,
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"status": "pending",
|
||||
"message": "Transcription intake recorded. A real transcription worker/provider is still required.",
|
||||
"status": "completed",
|
||||
"message": "Transcription completed.",
|
||||
"callId": body.callId,
|
||||
"recordingUrl": body.recordingUrl,
|
||||
"provider": result["provider"],
|
||||
"language": result["language"],
|
||||
"text": result["text"],
|
||||
"segments": result["segments"],
|
||||
}
|
||||
|
||||
@@ -77,6 +77,56 @@ CANONICAL_OPPORTUNITY_STAGES = (
|
||||
"closed_won",
|
||||
"closed_lost",
|
||||
)
|
||||
IMPORT_DUPLICATE_POLICIES = ("create_new", "update_existing", "skip_duplicate")
|
||||
CANONICAL_URGENCY_VALUES = ("low", "medium", "high", "critical")
|
||||
CANONICAL_TASK_PRIORITIES = ("low", "normal", "high", "urgent")
|
||||
CANONICAL_BUYER_TYPES = (
|
||||
"end_user",
|
||||
"hni_end_user",
|
||||
"nri_investor",
|
||||
"family_office",
|
||||
"founder_buyer",
|
||||
"broker_referral",
|
||||
"investor",
|
||||
)
|
||||
DREAM_WEAVER_ROOM_TYPES = (
|
||||
("bedroom", "Bedroom", "bed.double"),
|
||||
("living_room", "Living Room", "sofa"),
|
||||
("bathroom", "Bathroom", "drop"),
|
||||
("kitchen", "Kitchen", "refrigerator"),
|
||||
("dining_room", "Dining Room", "fork.knife"),
|
||||
("home_office", "Office", "desktopcomputer"),
|
||||
("hallway", "Hallway", "door.left.hand.open"),
|
||||
("balcony", "Balcony", "sun.max"),
|
||||
)
|
||||
|
||||
|
||||
def _label_for_vocab(value: str) -> str:
|
||||
return value.replace("_", " ").title()
|
||||
|
||||
|
||||
def _vocab_options(values: tuple[str, ...], descriptions: dict[str, str] | None = None) -> list[dict[str, str]]:
|
||||
descriptions = descriptions or {}
|
||||
return [
|
||||
{
|
||||
"value": value,
|
||||
"label": _label_for_vocab(value),
|
||||
"description": descriptions.get(value, ""),
|
||||
}
|
||||
for value in values
|
||||
]
|
||||
|
||||
|
||||
def _room_type_options() -> list[dict[str, str]]:
|
||||
return [
|
||||
{
|
||||
"value": value,
|
||||
"label": label,
|
||||
"description": "",
|
||||
"icon": icon,
|
||||
}
|
||||
for value, label, icon in DREAM_WEAVER_ROOM_TYPES
|
||||
]
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
@@ -176,12 +226,53 @@ def _opportunity_payload(row) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
@router.get("/crm/vocabularies", tags=["CRM Vocabularies"])
|
||||
async def get_crm_vocabularies(
|
||||
request: Request,
|
||||
user: UserPrincipal = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Canonical business vocabularies for native clients.
|
||||
|
||||
The iPad must not own CRM funnel semantics, duplicate merge policy values,
|
||||
task priorities, buyer personas, or Dream Weaver room vocabularies. This
|
||||
endpoint gives authenticated clients a single backend-owned contract.
|
||||
"""
|
||||
await _get_pool(request)
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"lead_statuses": _vocab_options(CANONICAL_LEAD_STAGES),
|
||||
"urgencies": _vocab_options(CANONICAL_URGENCY_VALUES),
|
||||
"buyer_types": _vocab_options(CANONICAL_BUYER_TYPES),
|
||||
"task_priorities": _vocab_options(CANONICAL_TASK_PRIORITIES),
|
||||
"lead_stages": _vocab_options(CANONICAL_LEAD_STAGES),
|
||||
"opportunity_stages": _vocab_options(CANONICAL_OPPORTUNITY_STAGES),
|
||||
"import_duplicate_policies": _vocab_options(
|
||||
IMPORT_DUPLICATE_POLICIES,
|
||||
{
|
||||
"create_new": "Create a new canonical CRM person from the approved row.",
|
||||
"update_existing": "Merge approved fields into the strongest duplicate candidate.",
|
||||
"skip_duplicate": "Skip canonical create/update for this approved row.",
|
||||
},
|
||||
),
|
||||
"dream_weaver_room_types": _room_type_options(),
|
||||
},
|
||||
"meta": {
|
||||
"tenant_id": _tenant_scope(user),
|
||||
"source_of_truth": "backend_canonical_crm_vocabulary",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── Models ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class ProposalApprovalRequest(BaseModel):
|
||||
proposal_id: str
|
||||
decision: str = Field(..., pattern="^(approved|rejected|needs_more_info)$")
|
||||
notes: str = Field(default="", max_length=2000)
|
||||
field_overrides: dict[str, Any] = Field(default_factory=dict)
|
||||
duplicate_policy: str = Field(default="create_new", pattern="^(create_new|update_existing|skip_duplicate)$")
|
||||
|
||||
|
||||
class CreatePersonRequest(BaseModel):
|
||||
@@ -228,6 +319,139 @@ class ClientDataPatchRequest(BaseModel):
|
||||
urgency: str | None = Field(default=None, max_length=64)
|
||||
|
||||
|
||||
IMPORT_VALIDATION_FIELDS = (
|
||||
"full_name",
|
||||
"primary_email",
|
||||
"primary_phone",
|
||||
"buyer_type",
|
||||
"budget_band",
|
||||
"project_name",
|
||||
)
|
||||
|
||||
|
||||
def _clean_import_value(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _digits_only(value: str | None) -> str:
|
||||
return "".join(ch for ch in (value or "") if ch.isdigit())
|
||||
|
||||
|
||||
def _validate_import_canonical(canonical: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
issues: list[dict[str, Any]] = []
|
||||
full_name = _clean_import_value(canonical.get("full_name"))
|
||||
email = _clean_import_value(canonical.get("primary_email"))
|
||||
phone = _clean_import_value(canonical.get("primary_phone"))
|
||||
|
||||
if not full_name:
|
||||
issues.append({
|
||||
"field": "full_name",
|
||||
"severity": "error",
|
||||
"message": "Full name is required before commit.",
|
||||
})
|
||||
elif len(full_name) < 2:
|
||||
issues.append({
|
||||
"field": "full_name",
|
||||
"severity": "warning",
|
||||
"message": "Full name is unusually short.",
|
||||
})
|
||||
|
||||
if email and ("@" not in email or "." not in email.split("@")[-1]):
|
||||
issues.append({
|
||||
"field": "primary_email",
|
||||
"severity": "error",
|
||||
"message": "Email must look like a valid address.",
|
||||
})
|
||||
|
||||
if phone and len(_digits_only(phone)) < 7:
|
||||
issues.append({
|
||||
"field": "primary_phone",
|
||||
"severity": "error",
|
||||
"message": "Phone number must contain at least 7 digits.",
|
||||
})
|
||||
|
||||
if not email and not phone:
|
||||
issues.append({
|
||||
"field": "primary_phone",
|
||||
"severity": "warning",
|
||||
"message": "No phone or email is present; future dedupe and outreach quality will be limited.",
|
||||
})
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def _crm_person_candidate(row: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"person_id": str(row["person_id"]),
|
||||
"full_name": row["full_name"],
|
||||
"primary_email": row["primary_email"],
|
||||
"primary_phone": row["primary_phone"],
|
||||
"buyer_type": row["buyer_type"],
|
||||
"source_confidence": float(row["source_confidence"]) if row["source_confidence"] is not None else None,
|
||||
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
|
||||
"updated_at": row["updated_at"].isoformat() if row["updated_at"] else None,
|
||||
"match_reason": row["match_reason"],
|
||||
"match_score": int(row["match_score"]),
|
||||
}
|
||||
|
||||
|
||||
def _proposal_field_diff(canonical: dict[str, Any], existing: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
fields = sorted(set(IMPORT_VALIDATION_FIELDS).union(canonical.keys()))
|
||||
diffs: list[dict[str, Any]] = []
|
||||
for field in fields:
|
||||
proposed = _clean_import_value(canonical.get(field))
|
||||
current = _clean_import_value(existing.get(field)) if existing else None
|
||||
if proposed is None and current is None:
|
||||
continue
|
||||
diffs.append({
|
||||
"field": field,
|
||||
"proposed": proposed,
|
||||
"existing": current,
|
||||
"changed": proposed != current,
|
||||
})
|
||||
return diffs
|
||||
|
||||
|
||||
async def _find_duplicate_person(conn, tenant_id: str, canonical: dict[str, Any]) -> Any | None:
|
||||
email = _clean_import_value(canonical.get("primary_email"))
|
||||
phone = _clean_import_value(canonical.get("primary_phone"))
|
||||
full_name = _clean_import_value(canonical.get("full_name"))
|
||||
return await conn.fetchrow(
|
||||
"""
|
||||
SELECT p.person_id, p.full_name, p.primary_email, p.primary_phone, p.buyer_type,
|
||||
p.source_confidence, p.created_at, p.updated_at,
|
||||
CASE
|
||||
WHEN $2::text IS NOT NULL AND LOWER(p.primary_email) = LOWER($2::text) THEN 'email'
|
||||
WHEN $3::text IS NOT NULL AND regexp_replace(COALESCE(p.primary_phone, ''), '\\D', '', 'g') = regexp_replace($3::text, '\\D', '', 'g') THEN 'phone'
|
||||
WHEN $4::text IS NOT NULL AND LOWER(p.full_name) = LOWER($4::text) THEN 'name'
|
||||
ELSE 'fuzzy'
|
||||
END AS match_reason,
|
||||
CASE
|
||||
WHEN $2::text IS NOT NULL AND LOWER(p.primary_email) = LOWER($2::text) THEN 100
|
||||
WHEN $3::text IS NOT NULL AND regexp_replace(COALESCE(p.primary_phone, ''), '\\D', '', 'g') = regexp_replace($3::text, '\\D', '', 'g') THEN 95
|
||||
WHEN $4::text IS NOT NULL AND LOWER(p.full_name) = LOWER($4::text) THEN 70
|
||||
ELSE 50
|
||||
END AS match_score
|
||||
FROM crm_people p
|
||||
WHERE p.tenant_id = $1
|
||||
AND (
|
||||
($2::text IS NOT NULL AND LOWER(p.primary_email) = LOWER($2::text))
|
||||
OR ($3::text IS NOT NULL AND regexp_replace(COALESCE(p.primary_phone, ''), '\\D', '', 'g') = regexp_replace($3::text, '\\D', '', 'g'))
|
||||
OR ($4::text IS NOT NULL AND LOWER(p.full_name) = LOWER($4::text))
|
||||
)
|
||||
ORDER BY match_score DESC, p.updated_at DESC NULLS LAST
|
||||
LIMIT 1
|
||||
""",
|
||||
tenant_id,
|
||||
email,
|
||||
phone,
|
||||
full_name,
|
||||
)
|
||||
|
||||
|
||||
class UpdateReminderRequest(BaseModel):
|
||||
status: str = Field(..., min_length=1, max_length=32)
|
||||
due_at: str | None = None
|
||||
@@ -419,6 +643,121 @@ async def get_import_batch(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/crm/imports/{batch_id}/workbench", tags=["CRM Imports"])
|
||||
async def get_import_workbench(
|
||||
request: Request,
|
||||
batch_id: str,
|
||||
user: UserPrincipal = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Return enterprise review diagnostics for an import batch.
|
||||
|
||||
This endpoint is intentionally read-only. It lets iPad/WebOS operators see
|
||||
per-field validation, duplicate candidates, and row-level diffs before
|
||||
committing approved proposals into canonical CRM tables.
|
||||
"""
|
||||
pool = await _get_pool(request)
|
||||
tenant_id = _tenant_scope(user)
|
||||
async with pool.acquire() as conn:
|
||||
batch_exists = await conn.fetchval(
|
||||
"SELECT EXISTS (SELECT 1 FROM workflow_import_batches WHERE batch_id = $1::uuid AND tenant_id = $2)",
|
||||
batch_id,
|
||||
tenant_id,
|
||||
)
|
||||
if not batch_exists:
|
||||
raise HTTPException(status_code=404, detail=f"Import batch '{batch_id}' not found.")
|
||||
|
||||
proposal_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT action_id, proposal_payload, confidence, status, approval_required, created_at
|
||||
FROM workflow_actions
|
||||
WHERE tenant_id = $1
|
||||
AND action_type = 'import_proposal'
|
||||
AND proposal_payload->>'batch_id' = $2
|
||||
ORDER BY (proposal_payload->>'row_number')::int ASC
|
||||
LIMIT 200
|
||||
""",
|
||||
tenant_id,
|
||||
batch_id,
|
||||
)
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
duplicate_count = 0
|
||||
validation_error_count = 0
|
||||
validation_warning_count = 0
|
||||
|
||||
for proposal in proposal_rows:
|
||||
payload = dict(proposal["proposal_payload"] or {})
|
||||
canonical = dict(payload.get("canonical_payload") or {})
|
||||
email = _clean_import_value(canonical.get("primary_email"))
|
||||
phone = _clean_import_value(canonical.get("primary_phone"))
|
||||
full_name = _clean_import_value(canonical.get("full_name"))
|
||||
|
||||
duplicate_candidates = await conn.fetch(
|
||||
"""
|
||||
SELECT p.person_id, p.full_name, p.primary_email, p.primary_phone, p.buyer_type,
|
||||
p.source_confidence, p.created_at, p.updated_at,
|
||||
CASE
|
||||
WHEN $2::text IS NOT NULL AND LOWER(p.primary_email) = LOWER($2::text) THEN 'email'
|
||||
WHEN $3::text IS NOT NULL AND regexp_replace(COALESCE(p.primary_phone, ''), '\\D', '', 'g') = regexp_replace($3::text, '\\D', '', 'g') THEN 'phone'
|
||||
WHEN $4::text IS NOT NULL AND LOWER(p.full_name) = LOWER($4::text) THEN 'name'
|
||||
ELSE 'fuzzy'
|
||||
END AS match_reason,
|
||||
CASE
|
||||
WHEN $2::text IS NOT NULL AND LOWER(p.primary_email) = LOWER($2::text) THEN 100
|
||||
WHEN $3::text IS NOT NULL AND regexp_replace(COALESCE(p.primary_phone, ''), '\\D', '', 'g') = regexp_replace($3::text, '\\D', '', 'g') THEN 95
|
||||
WHEN $4::text IS NOT NULL AND LOWER(p.full_name) = LOWER($4::text) THEN 70
|
||||
ELSE 50
|
||||
END AS match_score
|
||||
FROM crm_people p
|
||||
WHERE p.tenant_id = $1
|
||||
AND (
|
||||
($2::text IS NOT NULL AND LOWER(p.primary_email) = LOWER($2::text))
|
||||
OR ($3::text IS NOT NULL AND regexp_replace(COALESCE(p.primary_phone, ''), '\\D', '', 'g') = regexp_replace($3::text, '\\D', '', 'g'))
|
||||
OR ($4::text IS NOT NULL AND LOWER(p.full_name) = LOWER($4::text))
|
||||
)
|
||||
ORDER BY match_score DESC, p.updated_at DESC NULLS LAST
|
||||
LIMIT 5
|
||||
""",
|
||||
tenant_id,
|
||||
email,
|
||||
phone,
|
||||
full_name,
|
||||
)
|
||||
candidates = [_crm_person_candidate(row) for row in duplicate_candidates]
|
||||
existing = candidates[0] if candidates else None
|
||||
validation = _validate_import_canonical(canonical)
|
||||
validation_error_count += sum(1 for issue in validation if issue["severity"] == "error")
|
||||
validation_warning_count += sum(1 for issue in validation if issue["severity"] == "warning")
|
||||
if candidates:
|
||||
duplicate_count += 1
|
||||
|
||||
rows.append({
|
||||
"proposal_id": str(proposal["action_id"]),
|
||||
"row_number": payload.get("row_number"),
|
||||
"status": proposal["status"],
|
||||
"confidence": float(proposal["confidence"]) if proposal["confidence"] else 0.0,
|
||||
"validation": validation,
|
||||
"duplicate_candidates": candidates,
|
||||
"duplicate_policy": payload.get("duplicate_policy") or ("update_existing" if candidates else "create_new"),
|
||||
"field_diffs": _proposal_field_diff(canonical, existing),
|
||||
})
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"batch_id": batch_id,
|
||||
"summary": {
|
||||
"proposal_count": len(rows),
|
||||
"duplicate_count": duplicate_count,
|
||||
"validation_error_count": validation_error_count,
|
||||
"validation_warning_count": validation_warning_count,
|
||||
},
|
||||
"rows": rows,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.put("/crm/imports/{batch_id}/review-proposal", tags=["CRM Imports"])
|
||||
async def review_proposal(
|
||||
request: Request,
|
||||
@@ -435,7 +774,7 @@ async def review_proposal(
|
||||
async with pool.acquire() as conn:
|
||||
action = await conn.fetchrow(
|
||||
"""
|
||||
SELECT action_id, confidence, approval_required
|
||||
SELECT action_id, confidence, approval_required, proposal_payload
|
||||
FROM workflow_actions
|
||||
WHERE action_id = $1::uuid
|
||||
AND tenant_id = $2
|
||||
@@ -449,7 +788,41 @@ async def review_proposal(
|
||||
raise HTTPException(status_code=404, detail="Proposal not found.")
|
||||
|
||||
decision_id = str(uuid.uuid4())
|
||||
new_status = "approved" if body.decision == "approved" else "rejected"
|
||||
new_status = {
|
||||
"approved": "approved",
|
||||
"rejected": "rejected",
|
||||
"needs_more_info": "review_required",
|
||||
}[body.decision]
|
||||
proposal_payload = dict(action["proposal_payload"] or {})
|
||||
if body.field_overrides:
|
||||
canonical_payload = dict(proposal_payload.get("canonical_payload") or {})
|
||||
cleaned_overrides = {
|
||||
key: value
|
||||
for key, value in body.field_overrides.items()
|
||||
if key and value is not None and str(value).strip()
|
||||
}
|
||||
canonical_payload.update(cleaned_overrides)
|
||||
if cleaned_overrides:
|
||||
missing_required = [
|
||||
field for field in proposal_payload.get("missing_required", [])
|
||||
if field not in cleaned_overrides
|
||||
]
|
||||
unresolved_fields = [
|
||||
field for field in proposal_payload.get("unresolved_fields", [])
|
||||
if field not in cleaned_overrides
|
||||
]
|
||||
proposal_payload["canonical_payload"] = canonical_payload
|
||||
proposal_payload["missing_required"] = missing_required
|
||||
proposal_payload["unresolved_fields"] = unresolved_fields
|
||||
proposal_payload["remediation"] = {
|
||||
"source": "ipad_import_workbench",
|
||||
"field_overrides": cleaned_overrides,
|
||||
"updated_at": _now(),
|
||||
"updated_by": user.user_id,
|
||||
}
|
||||
proposal_payload["duplicate_policy"] = body.duplicate_policy
|
||||
proposal_payload["duplicate_policy_updated_at"] = _now()
|
||||
proposal_payload["duplicate_policy_updated_by"] = user.user_id
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
@@ -465,11 +838,12 @@ async def review_proposal(
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE workflow_actions
|
||||
SET status = $1::wf_status, updated_at = NOW()
|
||||
WHERE action_id = $2::uuid
|
||||
AND tenant_id = $3
|
||||
SET status = $1::wf_status, proposal_payload = $2::jsonb, updated_at = NOW()
|
||||
WHERE action_id = $3::uuid
|
||||
AND tenant_id = $4
|
||||
""",
|
||||
new_status,
|
||||
json.dumps(proposal_payload),
|
||||
body.proposal_id,
|
||||
_tenant_scope(user),
|
||||
)
|
||||
@@ -519,30 +893,91 @@ async def commit_approved_proposals(
|
||||
try:
|
||||
payload = row["proposal_payload"]
|
||||
canonical = payload.get("canonical_payload", {})
|
||||
if not canonical.get("full_name"):
|
||||
validation_errors = [
|
||||
issue for issue in _validate_import_canonical(canonical)
|
||||
if issue["severity"] == "error"
|
||||
]
|
||||
if validation_errors:
|
||||
skipped += 1
|
||||
errors.append(
|
||||
f"Proposal {row['action_id']}: "
|
||||
+ "; ".join(issue["message"] for issue in validation_errors)
|
||||
)
|
||||
continue
|
||||
|
||||
person_id = str(uuid.uuid4())
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO crm_people (
|
||||
person_id, tenant_id, full_name, primary_email, primary_phone,
|
||||
buyer_type, source_confidence, metadata_json, created_at, updated_at
|
||||
) VALUES (
|
||||
$1::uuid, $2, $3, $4, $5, $6, $7, $8::jsonb, NOW(), NOW()
|
||||
duplicate_policy = payload.get("duplicate_policy") or "create_new"
|
||||
if duplicate_policy not in IMPORT_DUPLICATE_POLICIES:
|
||||
duplicate_policy = "create_new"
|
||||
|
||||
duplicate_person = await _find_duplicate_person(conn, _tenant_scope(user), canonical)
|
||||
if duplicate_person and duplicate_policy == "skip_duplicate":
|
||||
skipped += 1
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE workflow_actions
|
||||
SET status = 'executed'::wf_status, updated_at = NOW()
|
||||
WHERE action_id = $1::uuid
|
||||
AND tenant_id = $2
|
||||
""",
|
||||
row["action_id"],
|
||||
_tenant_scope(user),
|
||||
)
|
||||
continue
|
||||
|
||||
if duplicate_person and duplicate_policy == "update_existing":
|
||||
person_id = str(duplicate_person["person_id"])
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE crm_people
|
||||
SET full_name = COALESCE($3, full_name),
|
||||
primary_email = COALESCE($4, primary_email),
|
||||
primary_phone = COALESCE($5, primary_phone),
|
||||
buyer_type = COALESCE($6, buyer_type),
|
||||
source_confidence = GREATEST(COALESCE(source_confidence, 0), $7),
|
||||
metadata_json = COALESCE(metadata_json, '{}'::jsonb) || $8::jsonb,
|
||||
updated_at = NOW()
|
||||
WHERE person_id = $1::uuid
|
||||
AND tenant_id = $2
|
||||
""",
|
||||
person_id,
|
||||
_tenant_scope(user),
|
||||
canonical.get("full_name"),
|
||||
canonical.get("primary_email"),
|
||||
canonical.get("primary_phone"),
|
||||
canonical.get("buyer_type"),
|
||||
payload.get("confidence", 0.5),
|
||||
json.dumps({
|
||||
"source_batch": batch_id,
|
||||
"import_row": payload.get("row_number"),
|
||||
"duplicate_policy": duplicate_policy,
|
||||
"merged_from_import": True,
|
||||
}),
|
||||
)
|
||||
else:
|
||||
person_id = str(uuid.uuid4())
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO crm_people (
|
||||
person_id, tenant_id, full_name, primary_email, primary_phone,
|
||||
buyer_type, source_confidence, metadata_json, created_at, updated_at
|
||||
) VALUES (
|
||||
$1::uuid, $2, $3, $4, $5, $6, $7, $8::jsonb, NOW(), NOW()
|
||||
)
|
||||
ON CONFLICT DO NOTHING
|
||||
""",
|
||||
person_id,
|
||||
_tenant_scope(user),
|
||||
canonical.get("full_name"),
|
||||
canonical.get("primary_email"),
|
||||
canonical.get("primary_phone"),
|
||||
canonical.get("buyer_type"),
|
||||
payload.get("confidence", 0.5),
|
||||
json.dumps({
|
||||
"source_batch": batch_id,
|
||||
"import_row": payload.get("row_number"),
|
||||
"duplicate_policy": duplicate_policy,
|
||||
}),
|
||||
)
|
||||
ON CONFLICT DO NOTHING
|
||||
""",
|
||||
person_id,
|
||||
_tenant_scope(user),
|
||||
canonical.get("full_name"),
|
||||
canonical.get("primary_email"),
|
||||
canonical.get("primary_phone"),
|
||||
canonical.get("buyer_type"),
|
||||
payload.get("confidence", 0.5),
|
||||
json.dumps({"source_batch": batch_id, "import_row": payload.get("row_number")}),
|
||||
)
|
||||
|
||||
if canonical.get("status") or canonical.get("budget_band"):
|
||||
lead_id = str(uuid.uuid4())
|
||||
@@ -985,6 +1420,11 @@ async def create_task(
|
||||
"""Create a reminder / follow-up task."""
|
||||
pool = await _get_pool(request)
|
||||
reminder_id = str(uuid.uuid4())
|
||||
next_priority = _normalize_choice(
|
||||
body.priority,
|
||||
allowed=CANONICAL_TASK_PRIORITIES,
|
||||
field_name="task priority",
|
||||
)
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
due_dt = _parse_optional_datetime(body.due_at, field_name="due_at")
|
||||
@@ -1035,7 +1475,7 @@ async def create_task(
|
||||
body.title,
|
||||
body.notes,
|
||||
due_dt,
|
||||
body.priority,
|
||||
next_priority,
|
||||
)
|
||||
|
||||
return {"status": "ok", "data": {"reminder_id": reminder_id, "title": body.title}}
|
||||
@@ -1362,13 +1802,26 @@ async def list_client_data(
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> dict[str, Any]:
|
||||
pool = await _get_pool(request)
|
||||
params: list[Any] = []
|
||||
filter_params: list[Any] = []
|
||||
where = "1=1"
|
||||
if search:
|
||||
params.append(f"%{search.lower()}%")
|
||||
where = f"(lower(p.full_name) LIKE ${len(params)} OR lower(COALESCE(p.primary_phone,'')) LIKE ${len(params)} OR lower(COALESCE(p.primary_email,'')) LIKE ${len(params)} OR lower(COALESCE(pi.projects,'')) LIKE ${len(params)})"
|
||||
params.extend([limit, offset])
|
||||
filter_params.append(f"%{search.lower()}%")
|
||||
where = f"(lower(p.full_name) LIKE ${len(filter_params)} OR lower(COALESCE(p.primary_phone,'')) LIKE ${len(filter_params)} OR lower(COALESCE(p.primary_email,'')) LIKE ${len(filter_params)} OR lower(COALESCE(pi.projects,'')) LIKE ${len(filter_params)})"
|
||||
row_params = [*filter_params, limit, offset]
|
||||
async with pool.acquire() as conn:
|
||||
total_count = await conn.fetchval(
|
||||
f"""
|
||||
WITH interests AS (
|
||||
SELECT person_id, string_agg(DISTINCT project_name, ', ') AS projects
|
||||
FROM crm_property_interests GROUP BY person_id
|
||||
)
|
||||
SELECT COUNT(*)::int
|
||||
FROM crm_people p
|
||||
LEFT JOIN interests pi ON pi.person_id = p.person_id
|
||||
WHERE {where}
|
||||
""",
|
||||
*filter_params,
|
||||
)
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
WITH interests AS (
|
||||
@@ -1397,11 +1850,21 @@ async def list_client_data(
|
||||
LEFT JOIN read_next_best_action nba ON nba.person_id = p.person_id
|
||||
WHERE {where}
|
||||
ORDER BY lc.last_contact_at DESC NULLS LAST, qd_score DESC, p.full_name ASC
|
||||
LIMIT ${len(params)-1} OFFSET ${len(params)}
|
||||
LIMIT ${len(row_params)-1} OFFSET ${len(row_params)}
|
||||
""",
|
||||
*params,
|
||||
*row_params,
|
||||
)
|
||||
return {"status": "ok", "data": [dict(r) for r in rows], "meta": {"count": len(rows), "limit": limit, "offset": offset}}
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": [dict(r) for r in rows],
|
||||
"meta": {
|
||||
"count": len(rows),
|
||||
"total_count": total_count or 0,
|
||||
"has_more": offset + len(rows) < (total_count or 0),
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/crm/client-data/{person_id}", tags=["CRM Client Data"])
|
||||
@@ -1454,26 +1917,66 @@ async def get_client_data(request: Request, person_id: str) -> dict[str, Any]:
|
||||
|
||||
|
||||
@router.patch("/crm/client-data/{person_id}", tags=["CRM Client Data"])
|
||||
async def patch_client_data(request: Request, person_id: str, body: ClientDataPatchRequest) -> dict[str, Any]:
|
||||
async def patch_client_data(
|
||||
request: Request,
|
||||
person_id: str,
|
||||
body: ClientDataPatchRequest,
|
||||
user: UserPrincipal = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
pool = await _get_pool(request)
|
||||
payload = body.model_dump(exclude_unset=True)
|
||||
tenant_id = _tenant_scope(user)
|
||||
if "lead_status" in payload and payload["lead_status"] is not None:
|
||||
payload["lead_status"] = _normalize_choice(
|
||||
payload["lead_status"],
|
||||
allowed=CANONICAL_LEAD_STAGES,
|
||||
field_name="lead status",
|
||||
)
|
||||
if "urgency" in payload and payload["urgency"] is not None:
|
||||
payload["urgency"] = _normalize_choice(
|
||||
payload["urgency"],
|
||||
allowed=CANONICAL_URGENCY_VALUES,
|
||||
field_name="urgency",
|
||||
)
|
||||
person_fields = {k: payload[k] for k in ("full_name", "primary_email", "primary_phone", "buyer_type", "communication_preference", "best_contact_time") if k in payload}
|
||||
lead_fields = {k: payload[k] for k in ("budget_band", "urgency") if k in payload}
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
person_exists = await conn.fetchval(
|
||||
"SELECT EXISTS (SELECT 1 FROM crm_people WHERE person_id = $1::uuid AND tenant_id = $2)",
|
||||
person_id,
|
||||
tenant_id,
|
||||
)
|
||||
if not person_exists:
|
||||
raise HTTPException(status_code=404, detail=f"Client '{person_id}' not found.")
|
||||
if person_fields:
|
||||
sets = ", ".join(f"{key} = ${idx}" for idx, key in enumerate(person_fields, start=1))
|
||||
await conn.execute(f"UPDATE crm_people SET {sets}, updated_at = NOW() WHERE person_id = ${len(person_fields)+1}::uuid", *person_fields.values(), person_id)
|
||||
await conn.execute(
|
||||
f"UPDATE crm_people SET {sets}, updated_at = NOW() WHERE person_id = ${len(person_fields)+1}::uuid AND tenant_id = ${len(person_fields)+2}",
|
||||
*person_fields.values(),
|
||||
person_id,
|
||||
tenant_id,
|
||||
)
|
||||
if lead_fields:
|
||||
lead_id = await conn.fetchval("SELECT lead_id FROM crm_leads WHERE person_id = $1::uuid ORDER BY updated_at DESC LIMIT 1", person_id)
|
||||
lead_id = await conn.fetchval(
|
||||
"SELECT lead_id FROM crm_leads WHERE person_id = $1::uuid AND tenant_id = $2 ORDER BY updated_at DESC LIMIT 1",
|
||||
person_id,
|
||||
tenant_id,
|
||||
)
|
||||
if lead_id:
|
||||
sets = ", ".join(f"{key} = ${idx}" for idx, key in enumerate(lead_fields, start=1))
|
||||
await conn.execute(f"UPDATE crm_leads SET {sets}, updated_at = NOW() WHERE lead_id = ${len(lead_fields)+1}::uuid", *lead_fields.values(), str(lead_id))
|
||||
await conn.execute(
|
||||
f"UPDATE crm_leads SET {sets}, updated_at = NOW() WHERE lead_id = ${len(lead_fields)+1}::uuid AND tenant_id = ${len(lead_fields)+2}",
|
||||
*lead_fields.values(),
|
||||
str(lead_id),
|
||||
tenant_id,
|
||||
)
|
||||
if "lead_status" in payload:
|
||||
await conn.execute(
|
||||
"UPDATE crm_leads SET status = $1::crm_lead_status, updated_at = NOW() WHERE person_id = $2::uuid",
|
||||
"UPDATE crm_leads SET status = $1::crm_lead_status, updated_at = NOW() WHERE person_id = $2::uuid AND tenant_id = $3",
|
||||
payload["lead_status"],
|
||||
person_id,
|
||||
tenant_id,
|
||||
)
|
||||
return {"status": "ok", "data": {"person_id": person_id, "updated": sorted(payload.keys())}}
|
||||
|
||||
@@ -1487,9 +1990,14 @@ async def get_client_data_timeline(request: Request, person_id: str, limit: int
|
||||
|
||||
|
||||
@router.post("/crm/client-data/{person_id}/tasks", status_code=201, tags=["CRM Client Data"])
|
||||
async def create_client_data_task(request: Request, person_id: str, body: CreateReminderRequest) -> dict[str, Any]:
|
||||
async def create_client_data_task(
|
||||
request: Request,
|
||||
person_id: str,
|
||||
body: CreateReminderRequest,
|
||||
user: UserPrincipal = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
patched = body.model_copy(update={"person_id": person_id})
|
||||
return await create_task(request, patched)
|
||||
return await create_task(request, patched, user)
|
||||
|
||||
|
||||
async def _client_timeline(conn: Any, person_id: str, limit: int) -> list[dict[str, Any]]:
|
||||
|
||||
@@ -5,6 +5,7 @@ Mobile Edge API — serves iPhone Edge and Android Phone Edge apps.
|
||||
|
||||
Surfaces:
|
||||
GET /mobile-edge/events — communication events for a lead
|
||||
GET /mobile-edge/bulk — coordinated iPad refresh bundle
|
||||
POST /mobile-edge/events — log a new communication event
|
||||
GET /mobile-edge/memory — memory facts for a lead
|
||||
POST /mobile-edge/imports — operator-assisted import of a recording/note
|
||||
@@ -54,6 +55,22 @@ def _tenant_scope(user) -> str:
|
||||
return user.tenant_id
|
||||
|
||||
|
||||
def _normalise_lead_ids(raw_value: Optional[str], max_items: int = 24) -> list[str]:
|
||||
if not raw_value:
|
||||
return []
|
||||
seen: set[str] = set()
|
||||
lead_ids: list[str] = []
|
||||
for part in raw_value.split(","):
|
||||
lead_id = part.strip()
|
||||
if not lead_id or lead_id in seen:
|
||||
continue
|
||||
seen.add(lead_id)
|
||||
lead_ids.append(lead_id)
|
||||
if len(lead_ids) >= max_items:
|
||||
break
|
||||
return lead_ids
|
||||
|
||||
|
||||
# ── Pydantic models ───────────────────────────────────────────────────────────
|
||||
|
||||
VALID_CHANNELS = {
|
||||
@@ -135,6 +152,12 @@ class SessionHeartbeat(BaseModel):
|
||||
metadata: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class MobileEdgeBulkRequest(BaseModel):
|
||||
lead_ids: list[str] = Field(default_factory=list, max_length=100)
|
||||
events_limit_per_lead: int = Field(default=4, ge=1, le=25)
|
||||
calendar_limit: int = Field(default=50, ge=1, le=200)
|
||||
|
||||
|
||||
# ── Communication Events ───────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/events", summary="List communication events for a lead")
|
||||
@@ -173,6 +196,134 @@ async def list_events(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/bulk", summary="Bulk mobile-edge refresh bundle")
|
||||
async def bulk_mobile_edge(
|
||||
request: Request,
|
||||
lead_ids: Optional[str] = Query(None, description="Comma-separated lead IDs to hydrate timeline events for"),
|
||||
events_limit_per_lead: int = Query(4, ge=1, le=25),
|
||||
calendar_limit: int = Query(50, ge=1, le=200),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Returns a single coordinated payload for native surface refreshes.
|
||||
|
||||
The iPad app uses this endpoint to avoid one request for alerts, one request
|
||||
for calendar, and then one request per lead timeline.
|
||||
"""
|
||||
return await _bulk_mobile_edge_payload(
|
||||
request=request,
|
||||
user=user,
|
||||
selected_lead_ids=_normalise_lead_ids(lead_ids),
|
||||
events_limit_per_lead=events_limit_per_lead,
|
||||
calendar_limit=calendar_limit,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/bulk", summary="Bulk mobile-edge refresh bundle")
|
||||
async def bulk_mobile_edge_post(
|
||||
request: Request,
|
||||
body: MobileEdgeBulkRequest,
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
"""POST variant for native clients that need a larger explicit lead set."""
|
||||
seen: set[str] = set()
|
||||
selected_lead_ids = []
|
||||
for lead_id in body.lead_ids:
|
||||
normalized = lead_id.strip()
|
||||
if normalized and normalized not in seen:
|
||||
seen.add(normalized)
|
||||
selected_lead_ids.append(normalized)
|
||||
return await _bulk_mobile_edge_payload(
|
||||
request=request,
|
||||
user=user,
|
||||
selected_lead_ids=selected_lead_ids[:100],
|
||||
events_limit_per_lead=body.events_limit_per_lead,
|
||||
calendar_limit=body.calendar_limit,
|
||||
)
|
||||
|
||||
|
||||
async def _bulk_mobile_edge_payload(
|
||||
*,
|
||||
request: Request,
|
||||
user,
|
||||
selected_lead_ids: list[str],
|
||||
events_limit_per_lead: int,
|
||||
calendar_limit: int,
|
||||
):
|
||||
tenant_id = _tenant_scope(user)
|
||||
pool = _pool(request)
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
calendar_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT calendar_event_id, lead_id, title, description, start_at, end_at,
|
||||
all_day, status, reminder_minutes, created_by, location, metadata, created_at
|
||||
FROM user_calendar_events
|
||||
WHERE tenant_id=$1 AND owner_user_id=$2
|
||||
AND status <> 'cancelled'
|
||||
ORDER BY start_at ASC LIMIT $3
|
||||
""",
|
||||
tenant_id, user.user_id, calendar_limit,
|
||||
)
|
||||
|
||||
if selected_lead_ids:
|
||||
event_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT event_id, lead_id, channel, direction, provider, capture_mode,
|
||||
consent_state, timestamp, duration_seconds, summary, raw_reference,
|
||||
recording_ref, provider_metadata, created_at
|
||||
FROM (
|
||||
SELECT event_id, lead_id, channel, direction, provider, capture_mode,
|
||||
consent_state, timestamp, duration_seconds, summary, raw_reference,
|
||||
recording_ref, provider_metadata, created_at,
|
||||
ROW_NUMBER() OVER (PARTITION BY lead_id ORDER BY timestamp DESC) AS row_number
|
||||
FROM edge_communication_events
|
||||
WHERE tenant_id=$1 AND lead_id = ANY($2::text[])
|
||||
) ranked_events
|
||||
WHERE row_number <= $3
|
||||
ORDER BY lead_id ASC, timestamp DESC
|
||||
""",
|
||||
tenant_id, selected_lead_ids, events_limit_per_lead,
|
||||
)
|
||||
else:
|
||||
event_rows = []
|
||||
|
||||
pending_insights = await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM insight_recommendations WHERE tenant_id=$1 AND status='pending'",
|
||||
tenant_id,
|
||||
)
|
||||
upcoming_events = await conn.fetchval(
|
||||
"""
|
||||
SELECT COUNT(*) FROM user_calendar_events
|
||||
WHERE tenant_id=$1 AND owner_user_id=$2
|
||||
AND status='confirmed'
|
||||
AND start_at BETWEEN NOW() AND NOW() + INTERVAL '24 hours'
|
||||
""",
|
||||
tenant_id, user.user_id,
|
||||
)
|
||||
pending_transcriptions = await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM edge_transcription_jobs WHERE tenant_id=$1 AND status='pending'",
|
||||
tenant_id,
|
||||
)
|
||||
|
||||
events_by_lead_id: dict[str, list[dict[str, Any]]] = {lead_id: [] for lead_id in selected_lead_ids}
|
||||
for row in event_rows:
|
||||
event = dict(row)
|
||||
events_by_lead_id.setdefault(event["lead_id"], []).append(event)
|
||||
|
||||
return {
|
||||
"calendar_events": [dict(r) for r in calendar_rows],
|
||||
"lead_events": events_by_lead_id,
|
||||
"alerts": {
|
||||
"pending_insights": pending_insights,
|
||||
"upcoming_calendar_events_24h": upcoming_events,
|
||||
"pending_transcriptions": pending_transcriptions,
|
||||
"generated_at": _now(),
|
||||
},
|
||||
"generated_at": _now(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/events", status_code=status.HTTP_201_CREATED, summary="Log a communication event")
|
||||
async def create_event(
|
||||
request: Request,
|
||||
|
||||
@@ -2,13 +2,15 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
import secrets
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.auth.dependencies import UserPrincipal, get_current_user
|
||||
from backend.auth.dependencies import UserPrincipal, create_access_token, get_current_user
|
||||
from backend.auth.service import (
|
||||
list_tenant_users,
|
||||
login_with_directory,
|
||||
@@ -31,6 +33,37 @@ class LoginRequest(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
class PasswordRecoveryRequest(BaseModel):
|
||||
email: str
|
||||
|
||||
|
||||
class SessionSwitchRequest(BaseModel):
|
||||
userId: str
|
||||
|
||||
|
||||
def _role_level(role: str) -> int:
|
||||
levels = {"JUNIOR_BROKER": 0, "SENIOR_BROKER": 1, "SALES_DIRECTOR": 2, "ADMIN": 3, "SUPERADMIN": 4}
|
||||
return levels.get(role.upper(), -1)
|
||||
|
||||
|
||||
def _sso_provider_descriptor(provider: str, tenant_id: str) -> dict[str, str | bool]:
|
||||
normalized = provider.strip().lower()
|
||||
env_prefix = f"VELOCITY_SSO_{normalized.upper().replace('-', '_')}"
|
||||
kind = os.getenv(f"{env_prefix}_TYPE", "oauth").strip().lower()
|
||||
return {
|
||||
"id": normalized,
|
||||
"name": os.getenv(f"{env_prefix}_NAME", normalized.replace("_", " ").title()),
|
||||
"type": kind,
|
||||
"tenantId": tenant_id,
|
||||
"authorizationUrl": os.getenv(f"{env_prefix}_AUTH_URL", ""),
|
||||
"tokenUrl": os.getenv(f"{env_prefix}_TOKEN_URL", ""),
|
||||
"issuer": os.getenv(f"{env_prefix}_ISSUER", ""),
|
||||
"clientId": os.getenv(f"{env_prefix}_CLIENT_ID", ""),
|
||||
"metadataUrl": os.getenv(f"{env_prefix}_METADATA_URL", ""),
|
||||
"enabled": bool(os.getenv(f"{env_prefix}_AUTH_URL") or os.getenv(f"{env_prefix}_METADATA_URL")),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/auth/login", tags=["Auth"])
|
||||
async def login(body: LoginRequest, request: Request):
|
||||
"""
|
||||
@@ -44,6 +77,138 @@ async def login(body: LoginRequest, request: Request):
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/auth/password-recovery", tags=["Auth"])
|
||||
async def request_password_recovery(body: PasswordRecoveryRequest, request: Request):
|
||||
await ensure_user_directory_schema(request.app)
|
||||
pool = getattr(request.app.state, "db_pool", None)
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="Database unavailable.")
|
||||
normalized_email = body.email.strip().lower()
|
||||
if not normalized_email or "@" not in normalized_email:
|
||||
raise HTTPException(status_code=422, detail="email is required.")
|
||||
raw_token = secrets.token_urlsafe(32)
|
||||
token_hash = hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(minutes=int(os.getenv("VELOCITY_PASSWORD_RECOVERY_MINUTES", "30")))
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS auth_password_recovery_requests (
|
||||
request_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email TEXT NOT NULL,
|
||||
token_hash TEXT,
|
||||
expires_at TIMESTAMPTZ,
|
||||
requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
status TEXT NOT NULL DEFAULT 'requested'
|
||||
)
|
||||
"""
|
||||
)
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO auth_password_recovery_requests (email, token_hash, expires_at)
|
||||
VALUES ($1, $2, $3)
|
||||
""",
|
||||
normalized_email,
|
||||
token_hash,
|
||||
expires_at,
|
||||
)
|
||||
response = {
|
||||
"status": "ok",
|
||||
"message": "Password recovery request recorded.",
|
||||
"expiresAt": expires_at.isoformat(),
|
||||
}
|
||||
if os.getenv("VELOCITY_AUTH_RETURN_RECOVERY_TOKEN", "").lower() in {"1", "true", "yes"}:
|
||||
response["recoveryToken"] = raw_token
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/api/auth/sso/providers", tags=["Auth"])
|
||||
async def list_sso_providers(user: UserPrincipal = Depends(get_current_user)):
|
||||
raw = os.getenv("VELOCITY_SSO_PROVIDERS", "")
|
||||
providers = [
|
||||
_sso_provider_descriptor(provider, user.tenant_id)
|
||||
for provider in raw.split(",")
|
||||
if provider.strip()
|
||||
]
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"enabled": bool(providers),
|
||||
"providers": providers,
|
||||
"tenantId": user.tenant_id,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/auth/sso/{provider_id}/start", tags=["Auth"])
|
||||
async def start_sso(provider_id: str, user: UserPrincipal = Depends(get_current_user)):
|
||||
provider = _sso_provider_descriptor(provider_id, user.tenant_id)
|
||||
if not provider["enabled"]:
|
||||
raise HTTPException(status_code=404, detail="SSO provider is not configured.")
|
||||
state = secrets.token_urlsafe(24)
|
||||
auth_url = str(provider["authorizationUrl"])
|
||||
separator = "&" if "?" in auth_url else "?"
|
||||
redirect_url = (
|
||||
f"{auth_url}{separator}"
|
||||
f"client_id={provider['clientId']}&"
|
||||
f"response_type=code&"
|
||||
f"scope=openid%20email%20profile&"
|
||||
f"state={state}"
|
||||
)
|
||||
return {"status": "ok", "data": {"provider": provider, "redirectUrl": redirect_url, "state": state}}
|
||||
|
||||
|
||||
@router.get("/api/auth/mdm/config", tags=["Auth"])
|
||||
async def get_mdm_config(user: UserPrincipal = Depends(get_current_user)):
|
||||
required = os.getenv("VELOCITY_MDM_REQUIRED", "").lower() in {"1", "true", "yes"}
|
||||
payload = {
|
||||
"VelocityBackendURL": os.getenv("VELOCITY_PUBLIC_BACKEND_URL", ""),
|
||||
"VelocityDreamWeaverURL": os.getenv("VELOCITY_DREAM_WEAVER_URL", ""),
|
||||
"VelocityTenantID": user.tenant_id,
|
||||
"VelocitySSOProvider": os.getenv("VELOCITY_DEFAULT_SSO_PROVIDER", ""),
|
||||
}
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"tenantId": user.tenant_id,
|
||||
"managedConfigurationRequired": required,
|
||||
"configurationKeys": list(payload.keys()),
|
||||
"payload": payload,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/auth/session-switch", tags=["Auth"])
|
||||
async def request_session_switch(
|
||||
body: SessionSwitchRequest,
|
||||
request: Request,
|
||||
user: UserPrincipal = Depends(get_current_user),
|
||||
):
|
||||
if user.role.upper() not in {"ADMIN", "SUPERADMIN"}:
|
||||
raise HTTPException(status_code=403, detail="Admin access required for session switching.")
|
||||
users = await list_tenant_users(app=request.app, user=user)
|
||||
target = next((item for item in users if str(item.get("user_id") or item.get("id")) == body.userId), None)
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail="Target user not found in tenant.")
|
||||
if _role_level(str(target.get("role") or "")) > _role_level(user.role):
|
||||
raise HTTPException(status_code=403, detail="Cannot switch into a higher-privilege account.")
|
||||
switched_token = create_access_token(
|
||||
user_id=target["user_id"],
|
||||
role=target["role"],
|
||||
tenant_id=target["tenant_id"],
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"switchAllowed": True,
|
||||
"targetUser": target,
|
||||
"requiresReauthentication": False,
|
||||
"accessToken": switched_token,
|
||||
"tokenType": "bearer",
|
||||
"expiresIn": 28800,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/auth/me", tags=["Auth"])
|
||||
async def me(request: Request, user: UserPrincipal = Depends(get_current_user)):
|
||||
return await read_authenticated_user_profile(app=request.app, user=user)
|
||||
@@ -51,7 +216,7 @@ async def me(request: Request, user: UserPrincipal = Depends(get_current_user)):
|
||||
|
||||
@router.get("/api/auth/users", tags=["Auth"])
|
||||
async def list_auth_users(request: Request, user: UserPrincipal = Depends(get_current_user)):
|
||||
return await list_tenant_users(app=request.app, user=user)
|
||||
return {"status": "ok", "data": await list_tenant_users(app=request.app, user=user)}
|
||||
|
||||
|
||||
@router.post("/api/auth/profile/avatar", tags=["Auth"])
|
||||
|
||||
@@ -55,11 +55,12 @@ def _load_velocity_env() -> None:
|
||||
_load_velocity_env()
|
||||
|
||||
from backend.api.routes_catalyst import router as catalyst_router
|
||||
from backend.api.routes_colony import router as colony_router
|
||||
from backend.api.routes_crm import crm_router, analytics_router
|
||||
from backend.api.routes_oracle import router as oracle_helper_router
|
||||
from backend.api.routes_mobile_edge import router as mobile_edge_router
|
||||
from backend.api.routes_inventory import router as inventory_router
|
||||
from backend.api.routes_admin_surface import router as admin_surface_router
|
||||
from backend.api.routes_admin_surface import dashboard_router, router as admin_surface_router
|
||||
from backend.api.routes_oracle_templates import router as oracle_templates_router
|
||||
from backend.api.routes_observability import router as observability_router
|
||||
from backend.api.routes_crm_imports import router as crm_imports_router
|
||||
@@ -136,6 +137,7 @@ if os.path.isdir(ASSET_DIR):
|
||||
# ── Routers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
app.include_router(catalyst_router, prefix="/api/catalyst", tags=["Catalyst"])
|
||||
app.include_router(colony_router, prefix="/api/colony", tags=["Colony"])
|
||||
app.include_router(crm_router, prefix="/api", tags=["CRM"])
|
||||
app.include_router(analytics_router, prefix="/api/analytics", tags=["Analytics"])
|
||||
app.include_router(oracle_helper_router, prefix="/api/oracle", tags=["Oracle"])
|
||||
@@ -149,6 +151,7 @@ app.include_router(vault_router, prefix="/api/vault", tags=["Vault"])
|
||||
app.include_router(mobile_edge_router, prefix="/api/mobile-edge", tags=["Mobile Edge"])
|
||||
app.include_router(inventory_router, prefix="/api/inventory", tags=["Inventory"])
|
||||
app.include_router(admin_surface_router, prefix="/api/admin-surface", tags=["Admin Surface"])
|
||||
app.include_router(dashboard_router, prefix="/api/dashboard", tags=["Dashboard"])
|
||||
app.include_router(observability_router, prefix="/api", tags=["Observability"])
|
||||
app.include_router(crm_imports_router, prefix="/api", tags=["CRM Canonical"])
|
||||
app.include_router(comms_router, prefix="/api/comms", tags=["Comms"])
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS colony_missions (
|
||||
mission_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id TEXT NOT NULL,
|
||||
mission_type TEXT NOT NULL CHECK (
|
||||
mission_type IN ('oracle_advisory', 'crm_lead_intelligence', 'catalyst_strategy_brief')
|
||||
),
|
||||
origin_surface TEXT NOT NULL DEFAULT 'api',
|
||||
actor_id TEXT NOT NULL,
|
||||
actor_role TEXT,
|
||||
risk_level TEXT NOT NULL DEFAULT 'low' CHECK (risk_level IN ('low', 'medium', 'high')),
|
||||
sensitivity_class TEXT NOT NULL DEFAULT 'internal' CHECK (
|
||||
sensitivity_class IN ('public', 'internal', 'confidential')
|
||||
),
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (
|
||||
status IN ('pending', 'queued', 'running', 'review', 'completed', 'failed', 'dispatch_failed')
|
||||
),
|
||||
review_status TEXT CHECK (review_status IN ('pending', 'approved', 'rejected')),
|
||||
time_budget_ms INTEGER NOT NULL CHECK (time_budget_ms > 0),
|
||||
token_budget INTEGER NOT NULL CHECK (token_budget > 0),
|
||||
user_goal TEXT NOT NULL,
|
||||
normalized_goal TEXT NOT NULL,
|
||||
context_refs JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
requested_outputs JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
completed_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_colony_missions_tenant_created
|
||||
ON colony_missions (tenant_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_colony_missions_tenant_status
|
||||
ON colony_missions (tenant_id, status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS colony_tasks (
|
||||
task_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
mission_id UUID NOT NULL REFERENCES colony_missions (mission_id) ON DELETE CASCADE,
|
||||
tenant_id TEXT NOT NULL,
|
||||
parent_task_id UUID,
|
||||
agent_name TEXT NOT NULL,
|
||||
task_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (
|
||||
status IN ('pending', 'queued', 'running', 'completed', 'failed', 'cancelled')
|
||||
),
|
||||
input JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
output JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
error TEXT,
|
||||
started_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_colony_tasks_mission_created
|
||||
ON colony_tasks (mission_id, created_at ASC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS colony_worker_results (
|
||||
result_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
mission_id UUID NOT NULL REFERENCES colony_missions (mission_id) ON DELETE CASCADE,
|
||||
task_id UUID REFERENCES colony_tasks (task_id) ON DELETE SET NULL,
|
||||
tenant_id TEXT NOT NULL,
|
||||
agent_name TEXT NOT NULL,
|
||||
result_type TEXT NOT NULL,
|
||||
confidence NUMERIC(5, 4),
|
||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
citations JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_colony_worker_results_mission_created
|
||||
ON colony_worker_results (mission_id, created_at ASC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS colony_writeback_proposals (
|
||||
proposal_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
mission_id UUID NOT NULL REFERENCES colony_missions (mission_id) ON DELETE CASCADE,
|
||||
tenant_id TEXT NOT NULL,
|
||||
target_system TEXT NOT NULL,
|
||||
target_table TEXT,
|
||||
target_id TEXT,
|
||||
action TEXT NOT NULL,
|
||||
before_state JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
after_state JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
rationale TEXT,
|
||||
approval_status TEXT NOT NULL DEFAULT 'pending' CHECK (
|
||||
approval_status IN ('pending', 'approved', 'rejected', 'applied', 'failed')
|
||||
),
|
||||
approved_by TEXT,
|
||||
approved_at TIMESTAMPTZ,
|
||||
rejected_by TEXT,
|
||||
rejected_at TIMESTAMPTZ,
|
||||
rejection_reason TEXT,
|
||||
applied_at TIMESTAMPTZ,
|
||||
error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_colony_writebacks_mission_status
|
||||
ON colony_writeback_proposals (mission_id, approval_status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS colony_event_log (
|
||||
event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
mission_id UUID REFERENCES colony_missions (mission_id) ON DELETE CASCADE,
|
||||
tenant_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
actor TEXT,
|
||||
detail JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_colony_event_log_mission_created
|
||||
ON colony_event_log (mission_id, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS catalyst_social_posts (
|
||||
post_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
request_id UUID NOT NULL,
|
||||
tenant_id TEXT NOT NULL,
|
||||
actor_id TEXT NOT NULL,
|
||||
platform TEXT NOT NULL CHECK (platform IN ('facebook', 'instagram', 'linkedin', 'twitter')),
|
||||
post_type TEXT NOT NULL CHECK (post_type IN ('image', 'video', 'carousel', 'text', 'link')),
|
||||
caption TEXT NOT NULL,
|
||||
hashtags JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
media_url TEXT,
|
||||
media_path TEXT,
|
||||
link_url TEXT,
|
||||
status TEXT NOT NULL CHECK (status IN ('scheduled', 'publishing', 'published', 'failed')),
|
||||
scheduled_at TIMESTAMPTZ,
|
||||
published_at TIMESTAMPTZ,
|
||||
platform_post_id TEXT,
|
||||
engagement JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
error TEXT,
|
||||
platform_response 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_catalyst_social_posts_tenant_created
|
||||
ON catalyst_social_posts (tenant_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_catalyst_social_posts_tenant_status_scheduled
|
||||
ON catalyst_social_posts (tenant_id, status, scheduled_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_catalyst_social_posts_request
|
||||
ON catalyst_social_posts (request_id);
|
||||
@@ -24,12 +24,15 @@ import logging
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Set
|
||||
from typing import Any, Literal, Set
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.auth.dependencies import UserPrincipal, get_current_user
|
||||
from backend.services.colony_gateway import ColonyConfigurationError, ColonyGateway, ColonyGatewayError
|
||||
from backend.services.colony_repository import ColonyRepository
|
||||
from .canvas_service import canvas_service
|
||||
from .collaboration_service import collaboration_service
|
||||
from .action_service import oracle_action_service
|
||||
@@ -142,6 +145,263 @@ async def _resolve_page_id(request: Request, user: UserPrincipal, page_id: str)
|
||||
return str(me["defaultPageId"])
|
||||
|
||||
|
||||
def _oracle_prompt_complexity(prompt: str, conversation_context: list[dict[str, str]] | None = None) -> tuple[str, list[str]]:
|
||||
text = prompt.lower()
|
||||
reasons: list[str] = []
|
||||
complex_markers = (
|
||||
"multi-round",
|
||||
"multi round",
|
||||
"coordinate",
|
||||
"orchestrate",
|
||||
"compare",
|
||||
"writeback",
|
||||
"write back",
|
||||
"approve",
|
||||
"crm",
|
||||
"catalyst",
|
||||
"campaign",
|
||||
"social",
|
||||
"inventory",
|
||||
"next best action",
|
||||
"next-best action",
|
||||
"follow-up plan",
|
||||
"strategy",
|
||||
"across",
|
||||
)
|
||||
matched = [marker for marker in complex_markers if marker in text]
|
||||
if matched:
|
||||
reasons.append(f"complex markers: {', '.join(matched[:5])}")
|
||||
if len(prompt) > 700:
|
||||
reasons.append("long prompt")
|
||||
if conversation_context and len(conversation_context) >= 4:
|
||||
reasons.append("multi-turn context")
|
||||
return ("thinking" if reasons else "fast", reasons)
|
||||
|
||||
|
||||
def _next_canvas_order(components: list[dict[str, Any]]) -> int:
|
||||
highest = 0
|
||||
for component in components:
|
||||
try:
|
||||
highest = max(highest, int((component.get("layout") or {}).get("orderIndex", 0)))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return ((highest // 100) + 1) * 100
|
||||
|
||||
|
||||
def _build_colony_status_component(
|
||||
*,
|
||||
execution_id: str,
|
||||
mission_id: str,
|
||||
prompt: str,
|
||||
actor_id: str,
|
||||
branch_id: str,
|
||||
mode: str,
|
||||
reasons: list[str],
|
||||
order_index: int,
|
||||
) -> dict[str, Any]:
|
||||
reason_text = "; ".join(reasons) if reasons else "operator selected thinking mode"
|
||||
return {
|
||||
"componentId": str(uuid.uuid4()),
|
||||
"type": "textCanvas",
|
||||
"title": "Colony Mission Dispatched",
|
||||
"description": "Oracle routed a complex request to Colony orchestration.",
|
||||
"dataSourceDescriptor": {
|
||||
"descriptorId": str(uuid.uuid4()),
|
||||
"sourceType": "api",
|
||||
"connectorId": "velocity-colony",
|
||||
"dataset": "colony_mission",
|
||||
"authContextRef": f"authctx_{actor_id}_scope",
|
||||
"queryTemplate": f"/api/colony/missions/{mission_id}",
|
||||
"queryParameters": {"missionId": mission_id},
|
||||
"rowLimit": 1,
|
||||
"privacyTier": "standard",
|
||||
"cachePolicy": {"mode": "none"},
|
||||
},
|
||||
"visualizationParameters": {
|
||||
"content": (
|
||||
f"Oracle routed this request to Colony because it needs deeper orchestration.\n\n"
|
||||
f"Mission ID: {mission_id}\n"
|
||||
f"Mode: {mode}\n"
|
||||
f"Routing reason: {reason_text}\n\n"
|
||||
f"Original request: {prompt}\n\n"
|
||||
"Colony will coordinate specialist workers and return artifacts/writeback proposals for review."
|
||||
),
|
||||
"widthMode": "full",
|
||||
"adjustableHeight": True,
|
||||
},
|
||||
"dataBindings": {"dimensions": [], "measures": [], "series": [], "filters": []},
|
||||
"version": 1,
|
||||
"lifecycleState": "active",
|
||||
"provenance": {
|
||||
"originType": "prompt_generated",
|
||||
"promptExecutionId": execution_id,
|
||||
"sourceBranchId": branch_id,
|
||||
"createdBy": actor_id,
|
||||
"createdAt": _now(),
|
||||
"colonyMissionId": mission_id,
|
||||
},
|
||||
"renderingHints": {"estimatedHeightPx": 220, "skeletonVariant": "text", "virtualizationPriority": 5},
|
||||
"layout": {
|
||||
"orderIndex": order_index,
|
||||
"sectionId": f"sec_colony_{execution_id.replace('-', '')[:12]}",
|
||||
"widthMode": "full",
|
||||
"minHeightPx": 220,
|
||||
"stickyHeader": False,
|
||||
},
|
||||
"accessControls": {
|
||||
"visibilityScope": "private",
|
||||
"allowedRoles": ["senior_broker", "sales_director", "marketing_operator", "data_steward", "compliance_reviewer", "platform_admin"],
|
||||
"redactionPolicy": "none",
|
||||
},
|
||||
"styleSignature": {
|
||||
"theme": "velocity_glass",
|
||||
"paletteToken": "ocean_signal",
|
||||
"motionProfile": "calm_reveal",
|
||||
"density": "comfortable",
|
||||
"radiusScale": "lg",
|
||||
"typographyScale": "balanced",
|
||||
},
|
||||
"validationState": {
|
||||
"schema": "pass",
|
||||
"policy": "pass",
|
||||
"a11y": "pass",
|
||||
"performance": "pass",
|
||||
"status": "validated",
|
||||
},
|
||||
"auditLog": [f"aud_{execution_id}_colony_dispatch"],
|
||||
"dataRows": [{"missionId": mission_id, "mode": mode, "routingReason": reason_text}],
|
||||
}
|
||||
|
||||
|
||||
async def _dispatch_colony_from_oracle_prompt(
|
||||
*,
|
||||
request: Request,
|
||||
ctx: PolicyContext,
|
||||
page_id: str,
|
||||
payload: PromptSubmitRequest,
|
||||
resolved_mode: str,
|
||||
routing_reasons: list[str],
|
||||
) -> dict[str, Any]:
|
||||
pool = getattr(request.app.state, "db_pool", None)
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="Database unavailable.")
|
||||
try:
|
||||
gateway = ColonyGateway()
|
||||
except ColonyConfigurationError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
|
||||
repo = ColonyRepository(pool)
|
||||
mission_id = str(uuid.uuid4())
|
||||
execution_id = str(uuid.uuid4())
|
||||
mission = {
|
||||
"mission_id": mission_id,
|
||||
"mission_type": "oracle_advisory",
|
||||
"origin_surface": "oracle_canvas",
|
||||
"tenant_id": ctx.tenant_id,
|
||||
"actor_id": ctx.actor_id,
|
||||
"actor_role": ctx.actor_role,
|
||||
"risk_level": "medium",
|
||||
"sensitivity_class": "internal",
|
||||
"time_budget_ms": 120000,
|
||||
"token_budget": 24000,
|
||||
"user_goal": payload.prompt,
|
||||
"normalized_goal": payload.prompt,
|
||||
"context_refs": {
|
||||
"page_id": page_id,
|
||||
"branch_id": payload.branchId,
|
||||
"client_request_id": payload.clientRequestId,
|
||||
"execution_mode": payload.executionMode,
|
||||
"resolved_mode": resolved_mode,
|
||||
"target_lead_id": payload.targetLeadId,
|
||||
"conversation_context": payload.conversationContext,
|
||||
},
|
||||
"requested_outputs": ["canvas_artifacts", "summary", "writeback_proposals"],
|
||||
"payload": {
|
||||
"planned_writeback": payload.plannedWriteback,
|
||||
"placement_mode": payload.placementMode,
|
||||
"routing_reasons": routing_reasons,
|
||||
},
|
||||
}
|
||||
row = await repo.create_mission(mission)
|
||||
await repo.log_event(
|
||||
mission_id=mission_id,
|
||||
tenant_id=ctx.tenant_id,
|
||||
event_type="mission_created_from_oracle_canvas",
|
||||
actor=ctx.actor_id,
|
||||
detail={"page_id": page_id, "execution_id": execution_id, "resolved_mode": resolved_mode},
|
||||
)
|
||||
try:
|
||||
dispatch = await gateway.dispatch_mission(mission)
|
||||
except (ColonyGatewayError, httpx.HTTPError) as exc:
|
||||
await repo.update_status(mission_id, ctx.tenant_id, "dispatch_failed")
|
||||
await repo.log_event(
|
||||
mission_id=mission_id,
|
||||
tenant_id=ctx.tenant_id,
|
||||
event_type="mission_dispatch_failed",
|
||||
actor=ctx.actor_id,
|
||||
detail={"error": str(exc)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={"message": str(exc), "mission_id": str(row["mission_id"])},
|
||||
) from exc
|
||||
|
||||
queued = await repo.update_status(mission_id, ctx.tenant_id, "queued")
|
||||
await repo.log_event(
|
||||
mission_id=mission_id,
|
||||
tenant_id=ctx.tenant_id,
|
||||
event_type="mission_dispatched",
|
||||
actor=ctx.actor_id,
|
||||
detail={"dispatch": dispatch},
|
||||
)
|
||||
|
||||
page = await canvas_service.get_page(page_id, ctx.tenant_id)
|
||||
existing_components = page.get("components", []) if page else []
|
||||
component = _build_colony_status_component(
|
||||
execution_id=execution_id,
|
||||
mission_id=mission_id,
|
||||
prompt=payload.prompt,
|
||||
actor_id=ctx.actor_id,
|
||||
branch_id=payload.branchId,
|
||||
mode=resolved_mode,
|
||||
reasons=routing_reasons,
|
||||
order_index=_next_canvas_order(existing_components),
|
||||
)
|
||||
revision = await canvas_service.commit_revision(
|
||||
page_id=page_id,
|
||||
tenant_id=ctx.tenant_id,
|
||||
actor_id=ctx.actor_id,
|
||||
commit_kind="prompt",
|
||||
commit_summary=f"Colony: {payload.prompt[:80]}",
|
||||
components=existing_components + [component],
|
||||
execution_id=execution_id,
|
||||
idempotency_key=payload.clientRequestId,
|
||||
)
|
||||
execution = {
|
||||
"executionId": execution_id,
|
||||
"tenantId": ctx.tenant_id,
|
||||
"pageId": page_id,
|
||||
"branchId": payload.branchId,
|
||||
"actorId": ctx.actor_id,
|
||||
"prompt": payload.prompt,
|
||||
"intentClass": "mixed",
|
||||
"status": "executing",
|
||||
"modelRuntime": "colony_orchestrator",
|
||||
"semanticModelVersion": "oracle_colony_router_v2026_05_03",
|
||||
"retrievalPlan": {"route": "colony", "missionId": mission_id, "dispatch": dispatch},
|
||||
"visualizationPlan": {"components": [component]},
|
||||
"warnings": [],
|
||||
"summary": f"Colony mission {mission_id} queued for multi-agent orchestration.",
|
||||
"componentsCreated": [component["componentId"]],
|
||||
"clientRequestId": payload.clientRequestId,
|
||||
"createdAt": _now(),
|
||||
"completedAt": None,
|
||||
"workflowDispatch": {"type": "colony_mission", "missionId": mission_id, "status": (queued or row)["status"]},
|
||||
}
|
||||
await prompt_orchestrator._persist_execution(execution)
|
||||
return {"execution": execution, "page": await canvas_service.get_page(page_id, ctx.tenant_id)}
|
||||
|
||||
|
||||
# ── Pydantic Models ───────────────────────────────────────────────────────────
|
||||
|
||||
class PromptSubmitRequest(BaseModel):
|
||||
@@ -150,6 +410,7 @@ class PromptSubmitRequest(BaseModel):
|
||||
prompt: str = Field(..., min_length=1, max_length=4096)
|
||||
conversationContext: list[dict[str, str]] = Field(default_factory=list)
|
||||
placementMode: str = Field("append_after_last_visible_component")
|
||||
executionMode: Literal["auto", "fast", "thinking"] = "auto"
|
||||
targetLeadId: str | None = None
|
||||
plannedWriteback: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@@ -202,6 +463,215 @@ class PageUpdateRequest(BaseModel):
|
||||
|
||||
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/mobile/team-performance", summary="Mobile Oracle team performance intelligence")
|
||||
async def mobile_team_performance(
|
||||
request: Request,
|
||||
limit: int = 12,
|
||||
user: UserPrincipal = Depends(get_current_user),
|
||||
) -> dict:
|
||||
pool = getattr(request.app.state, "db_pool", None)
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="Database unavailable.")
|
||||
tenant_id = user.tenant_id or _DEFAULT_TENANT_ID
|
||||
safe_limit = max(1, min(limit, 50))
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
WITH team AS (
|
||||
SELECT id, full_name, email, avatar_url
|
||||
FROM users_and_roles
|
||||
WHERE COALESCE(is_active, TRUE) = TRUE
|
||||
),
|
||||
lead_rollup AS (
|
||||
SELECT assigned_user_id,
|
||||
COUNT(*)::int AS assigned_leads,
|
||||
COUNT(*) FILTER (WHERE COALESCE(status::text, '') IN ('won', 'closed_won', 'booked'))::int AS won_leads
|
||||
FROM crm_leads
|
||||
WHERE tenant_id = $1
|
||||
GROUP BY assigned_user_id
|
||||
),
|
||||
opportunity_rollup AS (
|
||||
SELECT cl.assigned_user_id,
|
||||
COUNT(co.opportunity_id)::int AS active_opportunities,
|
||||
COALESCE(SUM(co.value) FILTER (WHERE COALESCE(co.stage::text, '') NOT IN ('closed_lost')), 0)::float AS pipeline_value,
|
||||
COALESCE(SUM(co.value) FILTER (WHERE COALESCE(co.stage::text, '') IN ('closed_won', 'won')), 0)::float AS closed_won_value
|
||||
FROM crm_opportunities co
|
||||
INNER JOIN crm_leads cl ON cl.lead_id = co.lead_id
|
||||
WHERE cl.tenant_id = $1
|
||||
GROUP BY cl.assigned_user_id
|
||||
),
|
||||
task_rollup AS (
|
||||
SELECT cl.assigned_user_id,
|
||||
COUNT(ir.reminder_id) FILTER (WHERE COALESCE(ir.status, '') IN ('pending', 'open', 'scheduled', 'snoozed'))::int AS open_tasks,
|
||||
COUNT(ir.reminder_id) FILTER (WHERE COALESCE(ir.status, '') = 'done')::int AS done_tasks
|
||||
FROM intel_reminders ir
|
||||
LEFT JOIN crm_leads cl ON cl.lead_id = ir.lead_id
|
||||
WHERE COALESCE(ir.tenant_id, $1) = $1
|
||||
GROUP BY cl.assigned_user_id
|
||||
),
|
||||
activity_rollup AS (
|
||||
SELECT cl.assigned_user_id, MAX(ii.happened_at) AS last_activity_at
|
||||
FROM intel_interactions ii
|
||||
LEFT JOIN crm_leads cl ON cl.lead_id = ii.lead_id
|
||||
WHERE COALESCE(ii.tenant_id, $1) = $1
|
||||
GROUP BY cl.assigned_user_id
|
||||
)
|
||||
SELECT
|
||||
t.id::text AS user_id,
|
||||
COALESCE(t.full_name, t.email, t.id::text) AS name,
|
||||
COALESCE(t.email, '') AS email,
|
||||
t.avatar_url,
|
||||
COALESCE(l.assigned_leads, 0)::int AS assigned_leads,
|
||||
COALESCE(tr.open_tasks, 0)::int AS open_tasks,
|
||||
COALESCE(tr.done_tasks, 0)::int AS done_tasks,
|
||||
COALESCE(o.active_opportunities, 0)::int AS active_opportunities,
|
||||
COALESCE(o.pipeline_value, 0)::float AS pipeline_value,
|
||||
COALESCE(o.closed_won_value, 0)::float AS closed_won_value,
|
||||
CASE WHEN COALESCE(l.assigned_leads, 0) = 0 THEN 0
|
||||
ELSE ROUND((COALESCE(l.won_leads, 0)::numeric / NULLIF(l.assigned_leads, 0)) * 100, 1)::float
|
||||
END AS conversion_rate,
|
||||
a.last_activity_at
|
||||
FROM team t
|
||||
LEFT JOIN lead_rollup l ON l.assigned_user_id = t.id
|
||||
LEFT JOIN opportunity_rollup o ON o.assigned_user_id = t.id
|
||||
LEFT JOIN task_rollup tr ON tr.assigned_user_id = t.id
|
||||
LEFT JOIN activity_rollup a ON a.assigned_user_id = t.id
|
||||
WHERE COALESCE(l.assigned_leads, 0) > 0
|
||||
OR COALESCE(o.active_opportunities, 0) > 0
|
||||
OR COALESCE(tr.open_tasks, 0) > 0
|
||||
ORDER BY COALESCE(o.closed_won_value, 0) DESC,
|
||||
COALESCE(o.pipeline_value, 0) DESC,
|
||||
COALESCE(l.assigned_leads, 0) DESC,
|
||||
name ASC
|
||||
LIMIT $2
|
||||
""",
|
||||
tenant_id,
|
||||
safe_limit,
|
||||
)
|
||||
performers = [
|
||||
{
|
||||
"userId": row["user_id"],
|
||||
"name": row["name"],
|
||||
"email": row["email"],
|
||||
"avatarUrl": row["avatar_url"],
|
||||
"assignedLeads": row["assigned_leads"],
|
||||
"openTasks": row["open_tasks"],
|
||||
"doneTasks": row["done_tasks"],
|
||||
"activeOpportunities": row["active_opportunities"],
|
||||
"pipelineValue": row["pipeline_value"],
|
||||
"closedWonValue": row["closed_won_value"],
|
||||
"conversionRate": row["conversion_rate"],
|
||||
"lastActivityAt": row["last_activity_at"].isoformat() if row["last_activity_at"] else None,
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
return _ok(
|
||||
{
|
||||
"summary": {
|
||||
"teamMembers": len(performers),
|
||||
"assignedLeads": sum(item["assignedLeads"] for item in performers),
|
||||
"openTasks": sum(item["openTasks"] for item in performers),
|
||||
"pipelineValue": sum(item["pipelineValue"] for item in performers),
|
||||
"closedWonValue": sum(item["closedWonValue"] for item in performers),
|
||||
},
|
||||
"performers": performers,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/mobile/lead-map", summary="Mobile Oracle lead geo-interest intelligence")
|
||||
async def mobile_lead_map(
|
||||
request: Request,
|
||||
limit: int = 24,
|
||||
user: UserPrincipal = Depends(get_current_user),
|
||||
) -> dict:
|
||||
pool = getattr(request.app.state, "db_pool", None)
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="Database unavailable.")
|
||||
tenant_id = user.tenant_id or _DEFAULT_TENANT_ID
|
||||
safe_limit = max(1, min(limit, 100))
|
||||
async with pool.acquire() as conn:
|
||||
has_rollup = await conn.fetchval("SELECT to_regclass('public.lead_geo_interest_rollup') IS NOT NULL")
|
||||
if has_rollup:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT district AS label,
|
||||
district AS district,
|
||||
NULL::text AS city,
|
||||
lat::float AS latitude,
|
||||
lng::float AS longitude,
|
||||
x::float AS x,
|
||||
y::float AS y,
|
||||
COALESCE(lead_count, 0)::int AS lead_count,
|
||||
COALESCE(avg_qd_score, 0)::float AS avg_qd_score,
|
||||
0::int AS hot_lead_count
|
||||
FROM lead_geo_interest_rollup
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY lead_count DESC, avg_qd_score DESC, district ASC
|
||||
LIMIT $2
|
||||
""",
|
||||
tenant_id,
|
||||
safe_limit,
|
||||
)
|
||||
else:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT
|
||||
COALESCE(NULLIF(p.city, ''), 'Unknown') AS label,
|
||||
COALESCE(NULLIF(p.city, ''), 'Unknown') AS city,
|
||||
NULL::text AS district,
|
||||
NULL::float AS latitude,
|
||||
NULL::float AS longitude,
|
||||
ROW_NUMBER() OVER (ORDER BY COUNT(*) DESC, COALESCE(NULLIF(p.city, ''), 'Unknown'))::float AS x,
|
||||
ROUND(AVG(COALESCE(q.current_value, 0.0))::numeric, 3)::float AS y,
|
||||
COUNT(DISTINCT p.person_id)::int AS lead_count,
|
||||
ROUND(AVG(COALESCE(q.current_value, 0.0))::numeric, 3)::float AS avg_qd_score,
|
||||
COUNT(DISTINCT p.person_id) FILTER (WHERE COALESCE(q.current_value, 0.0) >= 0.70)::int AS hot_lead_count
|
||||
FROM crm_people p
|
||||
LEFT JOIN crm_leads cl ON cl.person_id = p.person_id AND cl.tenant_id = $1
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT current_value
|
||||
FROM intel_qd_scores q
|
||||
WHERE q.person_id = p.person_id
|
||||
ORDER BY q.computed_at DESC
|
||||
LIMIT 1
|
||||
) q ON TRUE
|
||||
WHERE p.tenant_id = $1
|
||||
GROUP BY COALESCE(NULLIF(p.city, ''), 'Unknown')
|
||||
ORDER BY lead_count DESC, avg_qd_score DESC, label ASC
|
||||
LIMIT $2
|
||||
""",
|
||||
tenant_id,
|
||||
safe_limit,
|
||||
)
|
||||
points = [
|
||||
{
|
||||
"label": row["label"],
|
||||
"city": row["city"],
|
||||
"district": row["district"],
|
||||
"latitude": row["latitude"],
|
||||
"longitude": row["longitude"],
|
||||
"x": row["x"],
|
||||
"y": row["y"],
|
||||
"leadCount": row["lead_count"],
|
||||
"avgQdScore": row["avg_qd_score"],
|
||||
"hotLeadCount": row["hot_lead_count"],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
return _ok(
|
||||
{
|
||||
"summary": {
|
||||
"locations": len(points),
|
||||
"leadCount": sum(point["leadCount"] for point in points),
|
||||
"hotLeadCount": sum(point["hotLeadCount"] for point in points),
|
||||
},
|
||||
"points": points,
|
||||
},
|
||||
meta={"source": "lead_geo_interest_rollup" if has_rollup else "crm_people"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", summary="Get current user profile")
|
||||
async def get_me(request: Request, user: UserPrincipal = Depends(get_current_user)) -> dict:
|
||||
return _ok(await _get_current_user_profile(request, user))
|
||||
@@ -298,6 +768,50 @@ async def submit_prompt(
|
||||
) -> dict:
|
||||
page_id = await _resolve_page_id(request, user, page_id)
|
||||
ctx = await _ctx_from_request(request, user)
|
||||
complexity_route, routing_reasons = _oracle_prompt_complexity(payload.prompt, payload.conversationContext)
|
||||
resolved_mode = payload.executionMode
|
||||
if payload.executionMode == "auto":
|
||||
resolved_mode = complexity_route
|
||||
|
||||
if resolved_mode == "thinking":
|
||||
colony_result = await _dispatch_colony_from_oracle_prompt(
|
||||
request=request,
|
||||
ctx=ctx,
|
||||
page_id=page_id,
|
||||
payload=payload,
|
||||
resolved_mode=resolved_mode,
|
||||
routing_reasons=routing_reasons,
|
||||
)
|
||||
execution = colony_result["execution"]
|
||||
page = colony_result["page"]
|
||||
action = await oracle_action_service.create_from_execution(
|
||||
execution=execution,
|
||||
target_entity_type="lead" if payload.targetLeadId else "canvas_page",
|
||||
target_entity_id=payload.targetLeadId or page_id,
|
||||
action_type="oracle_colony_mission_dispatch",
|
||||
writeback_payload={
|
||||
**payload.plannedWriteback,
|
||||
"colonyMissionId": execution["workflowDispatch"]["missionId"],
|
||||
"executionMode": payload.executionMode,
|
||||
"resolvedMode": resolved_mode,
|
||||
},
|
||||
)
|
||||
return _ok({
|
||||
"executionId": execution["executionId"],
|
||||
"actionId": action["actionId"],
|
||||
"status": execution["status"],
|
||||
"executionMode": payload.executionMode,
|
||||
"resolvedMode": resolved_mode,
|
||||
"pageId": page_id,
|
||||
"branchId": payload.branchId,
|
||||
"headRevision": execution.get("headRevision", page.get("headRevision", 0) if page else 0),
|
||||
"componentsCreated": execution.get("componentsCreated", []),
|
||||
"summary": execution.get("summary", ""),
|
||||
"warnings": execution.get("warnings", []),
|
||||
"components": page.get("components", []) if page else [],
|
||||
"colonyMissionId": execution["workflowDispatch"]["missionId"],
|
||||
})
|
||||
|
||||
execution = await prompt_orchestrator.execute(
|
||||
tenant_id=ctx.tenant_id,
|
||||
page_id=page_id,
|
||||
@@ -326,6 +840,8 @@ async def submit_prompt(
|
||||
"executionId": execution["executionId"],
|
||||
"actionId": action["actionId"],
|
||||
"status": execution["status"],
|
||||
"executionMode": payload.executionMode,
|
||||
"resolvedMode": "fast",
|
||||
"pageId": page_id,
|
||||
"branchId": payload.branchId,
|
||||
"headRevision": execution.get("headRevision", page.get("headRevision", 0) if page else 0),
|
||||
@@ -551,4 +1067,3 @@ async def oracle_canvas_ws(ws: WebSocket, page_id: str) -> None:
|
||||
|
||||
|
||||
# ── Pre-made templates seed ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
@@ -16,7 +18,7 @@ import asyncpg
|
||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.auth.dependencies import UserPrincipal, require_role
|
||||
from backend.auth.dependencies import UserPrincipal, get_current_user, require_role
|
||||
from backend.db.pool import get_pool
|
||||
from backend.services.auto_mode_matcher import auto_mode_match_session
|
||||
from backend.services.nemoclaw_client import score_qd, tag_lead
|
||||
@@ -57,8 +59,12 @@ class SentinelConnectionManager:
|
||||
for channel in self._channels:
|
||||
await self.broadcast(payload, channel)
|
||||
|
||||
def connection_count(self, channel: str) -> int:
|
||||
return len(self._channels.get(channel, set()))
|
||||
|
||||
|
||||
manager = SentinelConnectionManager()
|
||||
_perception_producer_task: asyncio.Task | None = None
|
||||
|
||||
|
||||
def _is_uuid(value: str | None) -> bool:
|
||||
@@ -371,6 +377,115 @@ async def _persist_canonical_qd(
|
||||
)
|
||||
|
||||
|
||||
async def _build_showroom_perception_payload(pool: asyncpg.Pool) -> dict[str, Any]:
|
||||
now = datetime.now(timezone.utc)
|
||||
seconds = now.timestamp()
|
||||
simulated_count = max(1, int(round(9 + 5 * math.sin(seconds / 300.0) + 2 * math.sin(seconds / 53.0))))
|
||||
simulated_sentiment = max(0.0, min(1.0, 0.62 + 0.18 * math.sin(seconds / 210.0)))
|
||||
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE happened_at >= NOW() - INTERVAL '15 minutes')::int AS recent_events,
|
||||
COUNT(DISTINCT session_ref) FILTER (WHERE happened_at >= NOW() - INTERVAL '15 minutes')::int AS active_sessions,
|
||||
COALESCE(AVG(engagement_score) FILTER (WHERE happened_at >= NOW() - INTERVAL '15 minutes'), 0.0)::float AS avg_engagement,
|
||||
COUNT(*) FILTER (
|
||||
WHERE event_type IN ('engagement_spike', 'positive_shift')
|
||||
AND happened_at >= NOW() - INTERVAL '15 minutes'
|
||||
)::int AS positive_events,
|
||||
COUNT(*) FILTER (
|
||||
WHERE event_type = 'negative_shift'
|
||||
AND happened_at >= NOW() - INTERVAL '15 minutes'
|
||||
)::int AS negative_events
|
||||
FROM intel_perception_events
|
||||
"""
|
||||
)
|
||||
active_sessions = int(row["active_sessions"] or 0)
|
||||
recent_events = int(row["recent_events"] or 0)
|
||||
avg_engagement = float(row["avg_engagement"] or 0.0)
|
||||
if recent_events > 0:
|
||||
visitor_count = max(active_sessions, recent_events)
|
||||
sentiment_score = max(0.0, min(1.0, avg_engagement))
|
||||
source = "canonical_perception_events"
|
||||
else:
|
||||
visitor_count = simulated_count
|
||||
sentiment_score = simulated_sentiment
|
||||
source = "mathematical_simulation"
|
||||
positive_events = int(row["positive_events"] or 0)
|
||||
negative_events = int(row["negative_events"] or 0)
|
||||
except Exception:
|
||||
active_sessions = 0
|
||||
visitor_count = simulated_count
|
||||
sentiment_score = simulated_sentiment
|
||||
positive_events = 0
|
||||
negative_events = 0
|
||||
source = "mathematical_simulation"
|
||||
|
||||
showroom_heat = max(0.0, min(1.0, (visitor_count / 18.0) * 0.45 + sentiment_score * 0.55))
|
||||
conversion_intent = max(0.0, min(1.0, sentiment_score * 0.7 + min(visitor_count, 20) / 20.0 * 0.3))
|
||||
sentiment_label = "positive" if sentiment_score >= 0.66 else ("neutral" if sentiment_score >= 0.42 else "negative")
|
||||
|
||||
return {
|
||||
"type": "PERCEPTION_ANALYTICS",
|
||||
"data": {
|
||||
"generated_at": now.isoformat(),
|
||||
"source": source,
|
||||
"visitor_count": visitor_count,
|
||||
"active_sessions": active_sessions,
|
||||
"sentiment_score": round(sentiment_score, 3),
|
||||
"sentiment_label": sentiment_label,
|
||||
"positive_events": positive_events,
|
||||
"negative_events": negative_events,
|
||||
"showroom_intelligence": {
|
||||
"showroom_heat": round(showroom_heat, 3),
|
||||
"conversion_intent": round(conversion_intent, 3),
|
||||
"recommended_staffing": "high_touch" if visitor_count >= 12 or conversion_intent >= 0.72 else "standard",
|
||||
"zones": [
|
||||
{
|
||||
"zone": "model_apartment",
|
||||
"visitors": max(1, int(visitor_count * 0.42)),
|
||||
"dwell_seconds": int(220 + 140 * showroom_heat),
|
||||
},
|
||||
{
|
||||
"zone": "pricing_desk",
|
||||
"visitors": max(0, int(visitor_count * conversion_intent * 0.35)),
|
||||
"dwell_seconds": int(120 + 180 * conversion_intent),
|
||||
},
|
||||
{
|
||||
"zone": "amenities_gallery",
|
||||
"visitors": max(0, visitor_count - int(visitor_count * 0.42)),
|
||||
"dwell_seconds": int(90 + 90 * sentiment_score),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _perception_producer(pool: asyncpg.Pool) -> None:
|
||||
while True:
|
||||
try:
|
||||
if manager.connection_count("perception") == 0:
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
payload = await _build_showroom_perception_payload(pool)
|
||||
await manager.broadcast(payload, "perception")
|
||||
await asyncio.sleep(float(os.getenv("SENTINEL_PERCEPTION_INTERVAL_SECONDS", "3")))
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception("Sentinel perception producer failed: %s", exc)
|
||||
await asyncio.sleep(5.0)
|
||||
|
||||
|
||||
def _ensure_perception_producer(pool: asyncpg.Pool) -> None:
|
||||
global _perception_producer_task
|
||||
if _perception_producer_task is None or _perception_producer_task.done():
|
||||
_perception_producer_task = asyncio.create_task(_perception_producer(pool))
|
||||
|
||||
|
||||
@router.websocket("/ws/notifications")
|
||||
async def notifications_ws(ws: WebSocket) -> None:
|
||||
await manager.connect(ws, "notifications")
|
||||
@@ -390,6 +505,7 @@ async def perception_ws(ws: WebSocket) -> None:
|
||||
await ws.send_text(json.dumps({"type": "system", "data": {"error": "Database unavailable"}}))
|
||||
await ws.close(code=1011)
|
||||
return
|
||||
_ensure_perception_producer(pool)
|
||||
|
||||
try:
|
||||
while True:
|
||||
@@ -801,5 +917,78 @@ async def get_qd_score(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/analytics/live", summary="Live Sentinel perception analytics for native clients")
|
||||
async def live_perception_analytics(
|
||||
pool: asyncpg.Pool = Depends(get_pool),
|
||||
user: UserPrincipal = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
del user
|
||||
async with pool.acquire() as conn:
|
||||
session = await conn.fetchrow(
|
||||
"""
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE ended_at IS NULL)::int AS active_sessions,
|
||||
COUNT(*) FILTER (WHERE started_at >= NOW() - INTERVAL '24 hours')::int AS visitor_count_24h,
|
||||
COALESCE(ROUND(AVG(final_qd_score) FILTER (WHERE final_qd_score IS NOT NULL)::numeric, 1), 0)::float AS avg_qd_score
|
||||
FROM perception_sessions
|
||||
"""
|
||||
)
|
||||
sentiment_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT
|
||||
CASE
|
||||
WHEN event_type IN ('engagement_spike', 'positive_shift') OR COALESCE(engagement_score, 0) >= 0.70 THEN 'positive'
|
||||
WHEN event_type IN ('negative_shift', 'exit_risk') OR COALESCE(engagement_score, 0) <= 0.35 THEN 'negative'
|
||||
ELSE 'neutral'
|
||||
END AS bucket,
|
||||
COUNT(*)::int AS count
|
||||
FROM intel_perception_events
|
||||
WHERE happened_at >= NOW() - INTERVAL '24 hours'
|
||||
GROUP BY bucket
|
||||
"""
|
||||
)
|
||||
journey_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT
|
||||
perception_id::text,
|
||||
COALESCE(event_type, 'perception') AS event_type,
|
||||
COALESCE(session_ref, '') AS session_ref,
|
||||
COALESCE(media_ref, '') AS scene_label,
|
||||
COALESCE(engagement_score, 0)::float AS engagement_score,
|
||||
happened_at,
|
||||
COALESCE(metadata_json, '{}'::jsonb) AS metadata_json
|
||||
FROM intel_perception_events
|
||||
ORDER BY happened_at DESC
|
||||
LIMIT 12
|
||||
"""
|
||||
)
|
||||
sentiment = {"positive": 0, "neutral": 0, "negative": 0}
|
||||
for row in sentiment_rows:
|
||||
sentiment[row["bucket"]] = row["count"]
|
||||
journey = [
|
||||
{
|
||||
"eventId": row["perception_id"],
|
||||
"eventType": row["event_type"],
|
||||
"sessionRef": row["session_ref"],
|
||||
"sceneLabel": row["scene_label"],
|
||||
"engagementScore": row["engagement_score"],
|
||||
"happenedAt": row["happened_at"].isoformat() if row["happened_at"] else None,
|
||||
"metadata": dict(row["metadata_json"] or {}),
|
||||
}
|
||||
for row in journey_rows
|
||||
]
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"liveStreamPath": "/api/sentinel/ws/perception",
|
||||
"activeSessions": session["active_sessions"] if session else 0,
|
||||
"visitorCount24h": session["visitor_count_24h"] if session else 0,
|
||||
"averageQdScore": session["avg_qd_score"] if session else 0,
|
||||
"sentimentDistribution": sentiment,
|
||||
"journey": journey,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def broadcast_sentinel_event(payload: dict[str, Any]) -> None:
|
||||
await manager.broadcast(payload, "notifications")
|
||||
|
||||
152
backend/scripts/restore_and_seed_demo.sh
Executable file
152
backend/scripts/restore_and_seed_demo.sh
Executable file
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Velocity Backend — Recovery + Investor Demo Seed
|
||||
# =============================================================================
|
||||
# Run this on the production server (via SSH) to:
|
||||
# 1. Diagnose why the FastAPI backend is down (502 gateway)
|
||||
# 2. Restart the uvicorn service on port 8001
|
||||
# 3. Verify the /health endpoint responds through nginx
|
||||
# 4. Run the iPad investor-demo seed script to populate 50 realistic
|
||||
# clients, 11 properties with Unsplash media, calendar events, and tasks
|
||||
#
|
||||
# Usage:
|
||||
# ssh ubuntu@<SERVER_IP>
|
||||
# cd /path/to/Project_Velocity
|
||||
# bash backend/scripts/restore_and_seed_demo.sh
|
||||
#
|
||||
# Prerequisites on server:
|
||||
# - Python 3.11+
|
||||
# - backend/.env with DATABASE_URL or VELOCITY_DB_* vars set
|
||||
# - asyncpg installed (pip install -r backend/requirements.txt)
|
||||
# =============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
BACKEND_DIR="$REPO_ROOT/backend"
|
||||
VENV_PATH="${VENV_PATH:-$REPO_ROOT/.venv}"
|
||||
UVICORN_PORT="${UVICORN_PORT:-8001}"
|
||||
UVICORN_WORKERS="${UVICORN_WORKERS:-2}"
|
||||
OPERATOR_EMAIL="${VELOCITY_DEMO_OPERATOR_EMAIL:-sayan@desineuron.in}"
|
||||
|
||||
echo ""
|
||||
echo "============================================================"
|
||||
echo " Velocity Backend Recovery + Demo Seed"
|
||||
echo " Server: $(hostname)"
|
||||
echo " Repo: $REPO_ROOT"
|
||||
echo " Port: $UVICORN_PORT"
|
||||
echo "============================================================"
|
||||
echo ""
|
||||
|
||||
# ── Step 1: Diagnose ─────────────────────────────────────────────────────────
|
||||
echo "▶ Step 1/4 — Diagnosing current process state"
|
||||
|
||||
PIDS=$(lsof -ti tcp:"$UVICORN_PORT" 2>/dev/null || true)
|
||||
if [ -n "$PIDS" ]; then
|
||||
echo " ✓ Found process(es) on port $UVICORN_PORT: $PIDS"
|
||||
echo " Killing stale/hung processes..."
|
||||
kill -9 $PIDS 2>/dev/null || true
|
||||
sleep 1
|
||||
else
|
||||
echo " ✗ No process on port $UVICORN_PORT — backend is down. Will restart."
|
||||
fi
|
||||
|
||||
# Check systemd service if present
|
||||
if systemctl list-units --full --all | grep -q "velocity-backend"; then
|
||||
echo " Found systemd unit: velocity-backend"
|
||||
sudo systemctl stop velocity-backend 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ── Step 2: Activate venv and verify deps ────────────────────────────────────
|
||||
echo ""
|
||||
echo "▶ Step 2/4 — Activating virtualenv and verifying dependencies"
|
||||
|
||||
if [ -f "$VENV_PATH/bin/activate" ]; then
|
||||
source "$VENV_PATH/bin/activate"
|
||||
echo " ✓ Activated venv: $VENV_PATH"
|
||||
else
|
||||
echo " ! No venv at $VENV_PATH — using system Python"
|
||||
fi
|
||||
|
||||
PYTHON="${PYTHON:-python3}"
|
||||
|
||||
# Quick dep check
|
||||
$PYTHON -c "import fastapi, uvicorn, asyncpg, dotenv" 2>/dev/null || {
|
||||
echo " Installing backend requirements..."
|
||||
$PYTHON -m pip install --quiet -r "$BACKEND_DIR/requirements.txt"
|
||||
}
|
||||
echo " ✓ Dependencies OK"
|
||||
|
||||
# ── Step 3: Start uvicorn ─────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "▶ Step 3/4 — Starting uvicorn on port $UVICORN_PORT"
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
LOG_FILE="/tmp/velocity_backend_$(date +%Y%m%d_%H%M%S).log"
|
||||
|
||||
nohup $PYTHON -m uvicorn backend.main:app \
|
||||
--host 127.0.0.1 \
|
||||
--port "$UVICORN_PORT" \
|
||||
--workers "$UVICORN_WORKERS" \
|
||||
--log-level info \
|
||||
--timeout-keep-alive 75 \
|
||||
> "$LOG_FILE" 2>&1 &
|
||||
|
||||
UVICORN_PID=$!
|
||||
echo " uvicorn PID: $UVICORN_PID Log: $LOG_FILE"
|
||||
|
||||
# Wait for it to bind
|
||||
echo " Waiting for backend to accept connections..."
|
||||
MAX_WAIT=30
|
||||
WAITED=0
|
||||
until curl -sf "http://127.0.0.1:$UVICORN_PORT/health" > /dev/null 2>&1; do
|
||||
sleep 1
|
||||
WAITED=$((WAITED + 1))
|
||||
if [ $WAITED -ge $MAX_WAIT ]; then
|
||||
echo ""
|
||||
echo " ✗ Backend did not start within ${MAX_WAIT}s. Last log output:"
|
||||
tail -40 "$LOG_FILE"
|
||||
exit 1
|
||||
fi
|
||||
echo -n "."
|
||||
done
|
||||
echo ""
|
||||
|
||||
HEALTH=$(curl -sf "http://127.0.0.1:$UVICORN_PORT/health")
|
||||
echo " ✓ Backend healthy: $HEALTH"
|
||||
|
||||
# Verify nginx can reach it
|
||||
echo ""
|
||||
echo " Checking nginx → backend proxy..."
|
||||
if curl -sf "https://api.desineuron.in/health" > /dev/null 2>&1; then
|
||||
echo " ✓ api.desineuron.in/health is UP — 502 is resolved."
|
||||
else
|
||||
echo " ! api.desineuron.in/health still failing."
|
||||
echo " Check: sudo nginx -t && sudo systemctl reload nginx"
|
||||
echo " Continuing to seed anyway (backend is healthy on localhost)."
|
||||
fi
|
||||
|
||||
# ── Step 4: Seed investor demo data ──────────────────────────────────────────
|
||||
echo ""
|
||||
echo "▶ Step 4/4 — Running iPad investor demo seed"
|
||||
echo " Operator: $OPERATOR_EMAIL"
|
||||
echo " Target: 50 clients, 11 properties, media, tasks, calendar events"
|
||||
echo ""
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
$PYTHON backend/scripts/seed_ipad_investor_demo.py \
|
||||
--operator-email "$OPERATOR_EMAIL" \
|
||||
${VELOCITY_DEMO_TENANT_ID:+--tenant-id "$VELOCITY_DEMO_TENANT_ID"}
|
||||
|
||||
echo ""
|
||||
echo "============================================================"
|
||||
echo " ✅ Recovery complete."
|
||||
echo ""
|
||||
echo " Backend: http://127.0.0.1:$UVICORN_PORT (PID $UVICORN_PID)"
|
||||
echo " Public: https://api.desineuron.in"
|
||||
echo " Log: $LOG_FILE"
|
||||
echo ""
|
||||
echo " The iPad dashboard will show live data on next refresh."
|
||||
echo " Tap 'Updated now' in the header to trigger an immediate sync."
|
||||
echo "============================================================"
|
||||
echo ""
|
||||
@@ -13,6 +13,7 @@ import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
@@ -29,6 +30,7 @@ except ModuleNotFoundError: # pragma: no cover - exercised by operator environm
|
||||
SEED_SOURCE = "velocity_ipad_investor_demo_2026_04"
|
||||
DEFAULT_OPERATOR_EMAIL = "sayan@desineuron.in"
|
||||
NAMESPACE = uuid.uuid5(uuid.NAMESPACE_URL, "https://desineuron.in/project-velocity/ipad-investor-demo")
|
||||
DEMO_CLIENT_TARGET_COUNT = 50
|
||||
|
||||
|
||||
def _load_env() -> None:
|
||||
@@ -73,6 +75,7 @@ def _expected_counts() -> dict[str, int]:
|
||||
"leads": len(DEMO_CLIENTS),
|
||||
"projects": len(PROJECTS),
|
||||
"properties": len(PROJECTS),
|
||||
"property_media": len(PROJECTS) * 2,
|
||||
"interests": len(DEMO_CLIENTS),
|
||||
"opportunities": len(DEMO_CLIENTS),
|
||||
"scores": len(DEMO_CLIENTS) * 3,
|
||||
@@ -80,6 +83,10 @@ def _expected_counts() -> dict[str, int]:
|
||||
"edge_events": len(DEMO_CLIENTS),
|
||||
"reminders": len(DEMO_CLIENTS),
|
||||
"calendar_events": len(DEMO_CLIENTS),
|
||||
"insight_recommendations": len(DEMO_CLIENTS),
|
||||
"comms_threads": len(DEMO_CLIENTS),
|
||||
"comms_messages": len(DEMO_CLIENTS) * 3,
|
||||
"comms_call_logs": len(DEMO_CLIENTS),
|
||||
"import_batches": 1,
|
||||
"import_proposals": 3,
|
||||
}
|
||||
@@ -304,6 +311,201 @@ PROJECTS = {
|
||||
},
|
||||
}
|
||||
|
||||
PROJECTS.update(
|
||||
{
|
||||
"Emaar Palm Heights Dubai": {
|
||||
"developer": "Emaar Properties",
|
||||
"micro_market": "Dubai Hills Estate",
|
||||
"address": "Dubai Hills Estate, Dubai, UAE",
|
||||
"property_type": "penthouse",
|
||||
"location": {"city": "Dubai", "district": "Dubai Hills", "lat": 25.1132, "lng": 55.2477},
|
||||
"price_bands": [
|
||||
{"unitType": "4BR Sky Penthouse", "minUSD": 3200000, "maxUSD": 5200000},
|
||||
{"unitType": "3BR Residence", "minUSD": 1800000, "maxUSD": 2600000},
|
||||
],
|
||||
"unit_mix": [{"bedrooms": 4, "count": 16, "sizeSqft": 5200}, {"bedrooms": 3, "count": 42, "sizeSqft": 3100}],
|
||||
},
|
||||
"Sobha Hartland Estates": {
|
||||
"developer": "Sobha Realty",
|
||||
"micro_market": "Mohammed Bin Rashid City",
|
||||
"address": "Sobha Hartland, MBR City, Dubai, UAE",
|
||||
"property_type": "villa",
|
||||
"location": {"city": "Dubai", "district": "MBR City", "lat": 25.1763, "lng": 55.3067},
|
||||
"price_bands": [
|
||||
{"unitType": "5BR Waterfront Villa", "minUSD": 4500000, "maxUSD": 7800000},
|
||||
{"unitType": "4BR Garden Villa", "minUSD": 2900000, "maxUSD": 4200000},
|
||||
],
|
||||
"unit_mix": [{"bedrooms": 5, "count": 12, "sizeSqft": 8100}, {"bedrooms": 4, "count": 24, "sizeSqft": 5900}],
|
||||
},
|
||||
"Emaar Urban Oasis Gurugram": {
|
||||
"developer": "Emaar India",
|
||||
"micro_market": "Golf Course Extension",
|
||||
"address": "Sector 62, Gurugram, India",
|
||||
"property_type": "apartment",
|
||||
"location": {"city": "Gurugram", "district": "Golf Course Extension", "lat": 28.4059, "lng": 77.0964},
|
||||
"price_bands": [
|
||||
{"unitType": "4BHK Signature", "minINR": 95000000, "maxINR": 155000000},
|
||||
{"unitType": "Penthouse", "minINR": 180000000, "maxINR": 290000000},
|
||||
],
|
||||
"unit_mix": [{"bedrooms": 4, "count": 44, "sizeSqft": 4200}, {"bedrooms": 5, "count": 10, "sizeSqft": 6200}],
|
||||
},
|
||||
"Sobha Neopolis Presidential": {
|
||||
"developer": "Sobha Limited",
|
||||
"micro_market": "Panathur",
|
||||
"address": "Panathur Main Road, Bengaluru, India",
|
||||
"property_type": "apartment",
|
||||
"location": {"city": "Bengaluru", "district": "Panathur", "lat": 12.9357, "lng": 77.7037},
|
||||
"price_bands": [
|
||||
{"unitType": "4BHK Presidential", "minINR": 85000000, "maxINR": 140000000},
|
||||
{"unitType": "3BHK Luxury", "minINR": 42000000, "maxINR": 68000000},
|
||||
],
|
||||
"unit_mix": [{"bedrooms": 4, "count": 30, "sizeSqft": 3600}, {"bedrooms": 3, "count": 72, "sizeSqft": 2400}],
|
||||
},
|
||||
"Emaar Ocean Crown Abu Dhabi": {
|
||||
"developer": "Emaar Properties",
|
||||
"micro_market": "Saadiyat Island",
|
||||
"address": "Saadiyat Cultural District, Abu Dhabi, UAE",
|
||||
"property_type": "branded_residence",
|
||||
"location": {"city": "Abu Dhabi", "district": "Saadiyat Island", "lat": 24.5442, "lng": 54.4332},
|
||||
"price_bands": [
|
||||
{"unitType": "Royal Beachfront Penthouse", "minUSD": 6200000, "maxUSD": 11000000},
|
||||
{"unitType": "3BR Beach Residence", "minUSD": 2400000, "maxUSD": 3900000},
|
||||
],
|
||||
"unit_mix": [{"bedrooms": 5, "count": 6, "sizeSqft": 9200}, {"bedrooms": 3, "count": 28, "sizeSqft": 3400}],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
PROPERTY_MEDIA_URLS = [
|
||||
"https://images.unsplash.com/photo-1600585154340-be6161a56a0c",
|
||||
"https://images.unsplash.com/photo-1600607687939-ce8a6c25118c",
|
||||
"https://images.unsplash.com/photo-1600566753190-17f0baa2a6c3",
|
||||
"https://images.unsplash.com/photo-1600585154526-990dced4db0d",
|
||||
"https://images.unsplash.com/photo-1613490493576-7fde63acd811",
|
||||
"https://images.unsplash.com/photo-1600607687644-aac4c3eac7f4",
|
||||
"https://images.unsplash.com/photo-1600566752355-35792bedcfea",
|
||||
"https://images.unsplash.com/photo-1600607687920-4e2a09cf159d",
|
||||
]
|
||||
|
||||
|
||||
def _media_url(seed_index: int, *, width: int, height: int) -> str:
|
||||
base = PROPERTY_MEDIA_URLS[seed_index % len(PROPERTY_MEDIA_URLS)]
|
||||
return f"{base}?auto=format&fit=crop&w={width}&h={height}&q=82"
|
||||
|
||||
|
||||
def _project_media_assets(project_name: str, project: dict[str, Any], index: int) -> list[dict[str, Any]]:
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", project_name.lower()).strip("-")
|
||||
location = project["location"]
|
||||
return [
|
||||
{
|
||||
"key": f"{slug}:hero",
|
||||
"media_type": "image",
|
||||
"url": _media_url(index, width=1600, height=1000),
|
||||
"thumbnail_url": _media_url(index, width=640, height=420),
|
||||
"sort_order": 0,
|
||||
"metadata": {
|
||||
"seed_source": SEED_SOURCE,
|
||||
"demo_asset_kind": "property_hero",
|
||||
"project_name": project_name,
|
||||
"developer": project["developer"],
|
||||
"city": location["city"],
|
||||
"district": location["district"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"key": f"{slug}:floorplan",
|
||||
"media_type": "floorplan",
|
||||
"url": _media_url(index + 3, width=1600, height=1000),
|
||||
"thumbnail_url": _media_url(index + 3, width=640, height=420),
|
||||
"sort_order": 1,
|
||||
"metadata": {
|
||||
"seed_source": SEED_SOURCE,
|
||||
"demo_asset_kind": "floor_plan_reference",
|
||||
"project_name": project_name,
|
||||
"unit_mix": project["unit_mix"],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _phone_for(index: int, country: str) -> str:
|
||||
if country == "UAE":
|
||||
return f"+971-50-{7200000 + index:07d}"
|
||||
return f"+91-98{30000000 + index:08d}"[:14]
|
||||
|
||||
|
||||
def _expand_demo_clients(seed_clients: list[dict[str, Any]], target_count: int) -> list[dict[str, Any]]:
|
||||
if len(seed_clients) >= target_count:
|
||||
return seed_clients
|
||||
|
||||
first_names = [
|
||||
"Aarav", "Ishaan", "Kabir", "Reyansh", "Vihaan", "Anika", "Aanya", "Kiara",
|
||||
"Navya", "Rhea", "Omar", "Mariam", "Zayed", "Leila", "Farah", "Aditya",
|
||||
"Naina", "Rohan", "Tara", "Samaira", "Armaan", "Dev", "Saanvi", "Ayesha",
|
||||
]
|
||||
last_names = [
|
||||
"Mehta", "Kapoor", "Khanna", "Bhatia", "Sarin", "Al Maktoum", "Al Futtaim",
|
||||
"Al Habtoor", "Nair", "Menon", "Reddy", "Pillai", "Chopra", "Raheja",
|
||||
"Jindal", "Goenka", "Dalmia", "Merchant", "Saxena", "Bose",
|
||||
]
|
||||
buyer_types = ["hni_end_user", "nri_investor", "family_office", "founder_buyer", "investor"]
|
||||
statuses = ["qualified", "site_visit_scheduled", "site_visited", "negotiation", "booking_initiated", "contacted"]
|
||||
stages = ["qualified", "proposal", "site_visit", "negotiation", "booking"]
|
||||
urgencies = ["medium", "high", "critical"]
|
||||
project_names = list(PROJECTS.keys())
|
||||
expanded = list(seed_clients)
|
||||
|
||||
for index in range(len(seed_clients), target_count):
|
||||
first = first_names[index % len(first_names)]
|
||||
last = last_names[(index * 3) % len(last_names)]
|
||||
project_name = project_names[index % len(project_names)]
|
||||
project = PROJECTS[project_name]
|
||||
city = project["location"]["city"]
|
||||
is_uae = city in {"Dubai", "Abu Dhabi"}
|
||||
country = "UAE" if is_uae else "India"
|
||||
buyer_type = buyer_types[index % len(buyer_types)]
|
||||
urgency = urgencies[index % len(urgencies)]
|
||||
high_value_usd = 1_150_000 + (index % 9) * 425_000
|
||||
value = high_value_usd if is_uae else 82_000_000 + (index % 11) * 17_500_000
|
||||
budget_label = f"${high_value_usd / 1_000_000:.1f}-${(high_value_usd + 950_000) / 1_000_000:.1f}M" if is_uae else f"{int(value / 10_000_000)}-{int(value / 10_000_000) + 4} Cr"
|
||||
configuration = project["unit_mix"][0]["bedrooms"]
|
||||
unit_label = "Royal penthouse stack" if value >= (4_000_000 if is_uae else 180_000_000) else "High-floor signature residence"
|
||||
full_name = f"{first} {last}"
|
||||
key = re.sub(r"[^a-z0-9]+", "-", full_name.lower()).strip("-") + f"-{index:02d}"
|
||||
expanded.append(
|
||||
{
|
||||
"key": key,
|
||||
"name": full_name,
|
||||
"email": f"{key}@demo.desineuron.in",
|
||||
"phone": _phone_for(index, country),
|
||||
"buyer_type": buyer_type,
|
||||
"city": city,
|
||||
"nationality": "Emirati" if is_uae and index % 3 == 0 else "Indian",
|
||||
"persona": [buyer_type, "tier_1_developer_pitch", "high_value_pipeline"],
|
||||
"budget": budget_label,
|
||||
"urgency": urgency,
|
||||
"status": statuses[index % len(statuses)],
|
||||
"project": project_name,
|
||||
"configuration": f"{configuration}BR Signature Residence",
|
||||
"unit": unit_label,
|
||||
"budget_min": int(value * 0.88),
|
||||
"budget_max": int(value * 1.22),
|
||||
"stage": stages[index % len(stages)],
|
||||
"value": value,
|
||||
"probability": min(92, 48 + (index * 7) % 43),
|
||||
"next_action": f"Prepare developer-grade commercial deck for {project_name} and schedule executive walkthrough.",
|
||||
"scores": (
|
||||
round(0.68 + ((index * 7) % 28) / 100, 2),
|
||||
round(0.64 + ((index * 5) % 30) / 100, 2),
|
||||
round(0.62 + ((index * 11) % 35) / 100, 2),
|
||||
),
|
||||
}
|
||||
)
|
||||
return expanded
|
||||
|
||||
|
||||
DEMO_CLIENTS = _expand_demo_clients(DEMO_CLIENTS, DEMO_CLIENT_TARGET_COUNT)
|
||||
|
||||
|
||||
async def seed(conn: asyncpg.Connection, tenant_id: str, operator_user_id: str | None, dry_run: bool = False) -> dict[str, int]:
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -313,6 +515,7 @@ async def seed(conn: asyncpg.Connection, tenant_id: str, operator_user_id: str |
|
||||
"leads": 0,
|
||||
"projects": 0,
|
||||
"properties": 0,
|
||||
"property_media": 0,
|
||||
"interests": 0,
|
||||
"opportunities": 0,
|
||||
"scores": 0,
|
||||
@@ -320,6 +523,10 @@ async def seed(conn: asyncpg.Connection, tenant_id: str, operator_user_id: str |
|
||||
"edge_events": 0,
|
||||
"reminders": 0,
|
||||
"calendar_events": 0,
|
||||
"insight_recommendations": 0,
|
||||
"comms_threads": 0,
|
||||
"comms_messages": 0,
|
||||
"comms_call_logs": 0,
|
||||
"import_batches": 0,
|
||||
"import_proposals": 0,
|
||||
}
|
||||
@@ -327,7 +534,7 @@ async def seed(conn: asyncpg.Connection, tenant_id: str, operator_user_id: str |
|
||||
return counts
|
||||
|
||||
project_ids: dict[str, str] = {}
|
||||
for name, project in PROJECTS.items():
|
||||
for project_index, (name, project) in enumerate(PROJECTS.items()):
|
||||
project_id = _stable_uuid(tenant_id, f"project:{name}")
|
||||
project_ids[name] = project_id
|
||||
await conn.execute(
|
||||
@@ -336,8 +543,8 @@ async def seed(conn: asyncpg.Connection, tenant_id: str, operator_user_id: str |
|
||||
project_id, project_name, developer_name, city, micro_market, address,
|
||||
total_units, project_status, location_json, amenities_json, metadata_json
|
||||
) VALUES (
|
||||
$1::uuid, $2, $3, 'Kolkata', $4, $5, $6, 'active',
|
||||
$7::jsonb, $8::jsonb, $9::jsonb
|
||||
$1::uuid, $2, $3, $4, $5, $6, $7, 'active',
|
||||
$8::jsonb, $9::jsonb, $10::jsonb
|
||||
)
|
||||
ON CONFLICT (project_name) DO UPDATE SET
|
||||
developer_name = EXCLUDED.developer_name,
|
||||
@@ -353,6 +560,7 @@ async def seed(conn: asyncpg.Connection, tenant_id: str, operator_user_id: str |
|
||||
project_id,
|
||||
name,
|
||||
project["developer"],
|
||||
project["location"]["city"],
|
||||
project["micro_market"],
|
||||
project["address"],
|
||||
sum(item["count"] for item in project["unit_mix"]),
|
||||
@@ -399,6 +607,36 @@ async def seed(conn: asyncpg.Connection, tenant_id: str, operator_user_id: str |
|
||||
)
|
||||
counts["properties"] += 1
|
||||
|
||||
for media in _project_media_assets(name, project, project_index):
|
||||
media_asset_id = _stable_uuid(tenant_id, f"inventory-media:{media['key']}")
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO inventory_media_assets (
|
||||
media_asset_id, property_id, tenant_id, media_type, url,
|
||||
thumbnail_url, sort_order, metadata, uploaded_by, created_at
|
||||
) VALUES (
|
||||
$1::uuid, $2::uuid, $3, $4, $5, $6, $7, $8::jsonb, $9, NOW()
|
||||
)
|
||||
ON CONFLICT (media_asset_id) DO UPDATE SET
|
||||
media_type = EXCLUDED.media_type,
|
||||
url = EXCLUDED.url,
|
||||
thumbnail_url = EXCLUDED.thumbnail_url,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
metadata = inventory_media_assets.metadata || EXCLUDED.metadata,
|
||||
uploaded_by = EXCLUDED.uploaded_by
|
||||
""",
|
||||
media_asset_id,
|
||||
property_id,
|
||||
tenant_id,
|
||||
media["media_type"],
|
||||
media["url"],
|
||||
media["thumbnail_url"],
|
||||
media["sort_order"],
|
||||
_json(media["metadata"]),
|
||||
owner_user_ref,
|
||||
)
|
||||
counts["property_media"] += 1
|
||||
|
||||
for index, client in enumerate(DEMO_CLIENTS):
|
||||
person_id = _stable_uuid(tenant_id, f"person:{client['key']}")
|
||||
lead_id = _stable_uuid(tenant_id, f"lead:{client['key']}")
|
||||
@@ -707,6 +945,142 @@ async def seed(conn: asyncpg.Connection, tenant_id: str, operator_user_id: str |
|
||||
)
|
||||
counts["calendar_events"] += 1
|
||||
|
||||
recommendation_id = _stable_uuid(tenant_id, f"insight:{client['key']}")
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO insight_recommendations (
|
||||
recommendation_id, tenant_id, lead_id, source_event_id,
|
||||
recommendation_type, summary, suggested_action, target_system,
|
||||
status, confidence, created_at, updated_at
|
||||
) VALUES (
|
||||
$1::uuid, $2, $3, $4::uuid, $5, $6, $7, $8,
|
||||
'pending', $9, NOW() - ($10::int * INTERVAL '8 minutes'), NOW()
|
||||
)
|
||||
ON CONFLICT (recommendation_id) DO UPDATE SET
|
||||
source_event_id = EXCLUDED.source_event_id,
|
||||
recommendation_type = EXCLUDED.recommendation_type,
|
||||
summary = EXCLUDED.summary,
|
||||
suggested_action = EXCLUDED.suggested_action,
|
||||
target_system = EXCLUDED.target_system,
|
||||
status = 'pending',
|
||||
confidence = EXCLUDED.confidence,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
recommendation_id,
|
||||
tenant_id,
|
||||
lead_id,
|
||||
edge_event_id,
|
||||
"schedule_meeting" if client["urgency"] in {"high", "critical"} else "send_property_info",
|
||||
f"{client['name']} is showing strong buying intent for {client['project']}.",
|
||||
client["next_action"],
|
||||
"calendar" if client["urgency"] in {"high", "critical"} else "whatsapp",
|
||||
min(0.98, float(client["scores"][0]) + 0.04),
|
||||
index,
|
||||
)
|
||||
counts["insight_recommendations"] += 1
|
||||
|
||||
thread_id = _stable_uuid(tenant_id, f"comms-thread:{client['key']}")
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO comms_threads (
|
||||
thread_id, provider, external_thread_id, person_id, phone_e164,
|
||||
display_name, channel, status, assigned_user_id, last_message_at,
|
||||
unread_count, metadata_json, created_at, updated_at
|
||||
) VALUES (
|
||||
$1::uuid, 'waha', $2, $3::uuid, $4, $5, 'whatsapp',
|
||||
'open', $6::uuid, $7, 1, $8::jsonb, NOW() - INTERVAL '3 days', NOW()
|
||||
)
|
||||
ON CONFLICT (thread_id) DO UPDATE SET
|
||||
person_id = EXCLUDED.person_id,
|
||||
phone_e164 = EXCLUDED.phone_e164,
|
||||
display_name = EXCLUDED.display_name,
|
||||
assigned_user_id = EXCLUDED.assigned_user_id,
|
||||
last_message_at = EXCLUDED.last_message_at,
|
||||
unread_count = EXCLUDED.unread_count,
|
||||
metadata_json = comms_threads.metadata_json || EXCLUDED.metadata_json,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
thread_id,
|
||||
f"{SEED_SOURCE}:{client['key']}",
|
||||
person_id,
|
||||
client["phone"],
|
||||
client["name"],
|
||||
operator_user_id,
|
||||
now - timedelta(minutes=18 + index * 9),
|
||||
_json({"seed_source": SEED_SOURCE, "investor_demo": True, "project": client["project"]}),
|
||||
)
|
||||
counts["comms_threads"] += 1
|
||||
|
||||
message_bodies = [
|
||||
("inbound", f"Can you send the latest floor stack and payment plan for {client['project']}?"),
|
||||
("outbound", f"Sharing the executive deck now. I also reserved a walkthrough slot for {client['configuration']}."),
|
||||
("inbound", f"Please proceed. Budget is aligned around {client['budget']} if the view and handover schedule work."),
|
||||
]
|
||||
for message_index, (direction, body) in enumerate(message_bodies):
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO comms_messages (
|
||||
message_id, thread_id, provider, external_message_id, direction,
|
||||
message_type, body, delivery_status, sent_at, delivered_at,
|
||||
raw_payload, created_at
|
||||
) VALUES (
|
||||
$1::uuid, $2::uuid, 'waha', $3, $4, 'text', $5,
|
||||
'delivered', $6, $6, $7::jsonb, NOW()
|
||||
)
|
||||
ON CONFLICT (message_id) DO UPDATE SET
|
||||
body = EXCLUDED.body,
|
||||
delivery_status = EXCLUDED.delivery_status,
|
||||
sent_at = EXCLUDED.sent_at,
|
||||
delivered_at = EXCLUDED.delivered_at,
|
||||
raw_payload = EXCLUDED.raw_payload
|
||||
""",
|
||||
_stable_uuid(tenant_id, f"comms-message:{client['key']}:{message_index}"),
|
||||
thread_id,
|
||||
f"{SEED_SOURCE}:{client['key']}:{message_index}",
|
||||
direction,
|
||||
body,
|
||||
now - timedelta(minutes=46 - message_index * 14 + index * 3),
|
||||
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
|
||||
)
|
||||
counts["comms_messages"] += 1
|
||||
|
||||
call_id = _stable_uuid(tenant_id, f"comms-call:{client['key']}")
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO comms_call_logs (
|
||||
call_id, thread_id, person_id, provider, external_call_id,
|
||||
phone_e164, direction, status, started_at, ended_at,
|
||||
duration_seconds, recording_url, transcript_text, raw_payload,
|
||||
created_at
|
||||
) VALUES (
|
||||
$1::uuid, $2::uuid, $3::uuid, 'waha', $4, $5, 'outbound',
|
||||
'completed', $6, $7, $8, $9, $10, $11::jsonb, NOW()
|
||||
)
|
||||
ON CONFLICT (call_id) DO UPDATE SET
|
||||
thread_id = EXCLUDED.thread_id,
|
||||
person_id = EXCLUDED.person_id,
|
||||
status = EXCLUDED.status,
|
||||
started_at = EXCLUDED.started_at,
|
||||
ended_at = EXCLUDED.ended_at,
|
||||
duration_seconds = EXCLUDED.duration_seconds,
|
||||
recording_url = EXCLUDED.recording_url,
|
||||
transcript_text = EXCLUDED.transcript_text,
|
||||
raw_payload = EXCLUDED.raw_payload
|
||||
""",
|
||||
call_id,
|
||||
thread_id,
|
||||
person_id,
|
||||
f"{SEED_SOURCE}:call:{client['key']}",
|
||||
client["phone"],
|
||||
now - timedelta(hours=2 + index),
|
||||
now - timedelta(hours=2 + index) + timedelta(minutes=7, seconds=30),
|
||||
450,
|
||||
f"s3://velocity-demo-media/{SEED_SOURCE}/calls/{client['key']}.m4a",
|
||||
f"Discussed {client['project']}, {client['configuration']}, budget {client['budget']}, and next action: {client['next_action']}",
|
||||
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
|
||||
)
|
||||
counts["comms_call_logs"] += 1
|
||||
|
||||
batch_id = _stable_uuid(tenant_id, "workflow-import-batch:investor-demo")
|
||||
await conn.execute(
|
||||
"""
|
||||
|
||||
50
backend/services/colony_gateway.py
Normal file
50
backend/services/colony_gateway.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class ColonyConfigurationError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class ColonyGatewayError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class ColonyGateway:
|
||||
"""HTTP client from the Python root to the internal colony orchestrator."""
|
||||
|
||||
def __init__(self, *, base_url: str | None = None, timeout_s: float | None = None) -> None:
|
||||
resolved_base_url = (base_url or os.getenv("COLONY_SERVICE_URL", "")).strip().rstrip("/")
|
||||
if not resolved_base_url:
|
||||
raise ColonyConfigurationError("COLONY_SERVICE_URL is not configured.")
|
||||
self.base_url = resolved_base_url
|
||||
self.timeout = httpx.Timeout(timeout_s or float(os.getenv("COLONY_TIMEOUT_SECONDS", "30")))
|
||||
|
||||
async def health(self) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0)) as client:
|
||||
response = await client.get(f"{self.base_url}/health")
|
||||
if response.status_code >= 400:
|
||||
raise ColonyGatewayError(f"Colony service health check failed with HTTP {response.status_code}.")
|
||||
return response.json()
|
||||
|
||||
async def dispatch_mission(self, mission: dict[str, Any]) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(f"{self.base_url}/missions", json=mission)
|
||||
if response.status_code >= 400:
|
||||
raise ColonyGatewayError(
|
||||
f"Colony service rejected mission with HTTP {response.status_code}: {response.text}"
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def mission_status(self, mission_id: str) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(f"{self.base_url}/missions/{mission_id}/status")
|
||||
if response.status_code >= 400:
|
||||
raise ColonyGatewayError(
|
||||
f"Colony service status check failed with HTTP {response.status_code}: {response.text}"
|
||||
)
|
||||
return response.json()
|
||||
253
backend/services/colony_repository.py
Normal file
253
backend/services/colony_repository.py
Normal file
@@ -0,0 +1,253 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
|
||||
|
||||
_JSON_KEYS = {
|
||||
"context_refs",
|
||||
"requested_outputs",
|
||||
"payload",
|
||||
"input",
|
||||
"output",
|
||||
"citations",
|
||||
"before_state",
|
||||
"after_state",
|
||||
"detail",
|
||||
}
|
||||
|
||||
|
||||
def _row_dict(row: asyncpg.Record | None) -> dict[str, Any] | None:
|
||||
if row is None:
|
||||
return None
|
||||
data = dict(row)
|
||||
for key in _JSON_KEYS:
|
||||
if isinstance(data.get(key), str):
|
||||
data[key] = json.loads(data[key])
|
||||
return data
|
||||
|
||||
|
||||
class ColonyRepository:
|
||||
def __init__(self, pool: asyncpg.Pool) -> None:
|
||||
self.pool = pool
|
||||
|
||||
async def create_mission(self, mission: dict[str, Any]) -> dict[str, Any]:
|
||||
async with self.pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO colony_missions (
|
||||
mission_id, tenant_id, mission_type, origin_surface, actor_id,
|
||||
actor_role, risk_level, sensitivity_class, status, review_status,
|
||||
time_budget_ms, token_budget, user_goal, normalized_goal,
|
||||
context_refs, requested_outputs, payload, created_at, updated_at
|
||||
) VALUES (
|
||||
$1::uuid, $2, $3, $4, $5,
|
||||
$6, $7, $8, 'pending', NULL,
|
||||
$9, $10, $11, $12,
|
||||
$13::jsonb, $14::jsonb, $15::jsonb, NOW(), NOW()
|
||||
)
|
||||
RETURNING *
|
||||
""",
|
||||
mission["mission_id"],
|
||||
mission["tenant_id"],
|
||||
mission["mission_type"],
|
||||
mission["origin_surface"],
|
||||
mission["actor_id"],
|
||||
mission.get("actor_role"),
|
||||
mission["risk_level"],
|
||||
mission["sensitivity_class"],
|
||||
mission["time_budget_ms"],
|
||||
mission["token_budget"],
|
||||
mission["user_goal"],
|
||||
mission["normalized_goal"],
|
||||
json.dumps(mission["context_refs"]),
|
||||
json.dumps(mission["requested_outputs"]),
|
||||
json.dumps(mission["payload"]),
|
||||
)
|
||||
return _row_dict(row)
|
||||
|
||||
async def get_mission(self, mission_id: str, tenant_id: str) -> dict[str, Any] | None:
|
||||
async with self.pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT *
|
||||
FROM colony_missions
|
||||
WHERE mission_id = $1::uuid
|
||||
AND tenant_id = $2
|
||||
""",
|
||||
mission_id,
|
||||
tenant_id,
|
||||
)
|
||||
return _row_dict(row)
|
||||
|
||||
async def update_status(
|
||||
self,
|
||||
mission_id: str,
|
||||
tenant_id: str,
|
||||
status: str,
|
||||
*,
|
||||
review_status: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
async with self.pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
UPDATE colony_missions
|
||||
SET status = $3,
|
||||
review_status = COALESCE($4, review_status),
|
||||
updated_at = NOW(),
|
||||
completed_at = CASE
|
||||
WHEN $3 IN ('completed', 'failed', 'dispatch_failed') THEN NOW()
|
||||
ELSE completed_at
|
||||
END
|
||||
WHERE mission_id = $1::uuid
|
||||
AND tenant_id = $2
|
||||
RETURNING *
|
||||
""",
|
||||
mission_id,
|
||||
tenant_id,
|
||||
status,
|
||||
review_status,
|
||||
)
|
||||
return _row_dict(row)
|
||||
|
||||
async def list_missions(self, tenant_id: str, *, limit: int, offset: int) -> list[dict[str, Any]]:
|
||||
async with self.pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT *
|
||||
FROM colony_missions
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
""",
|
||||
tenant_id,
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
return [_row_dict(row) for row in rows]
|
||||
|
||||
async def artifacts(self, mission_id: str, tenant_id: str) -> dict[str, list[dict[str, Any]]]:
|
||||
async with self.pool.acquire() as conn:
|
||||
mission = await conn.fetchrow(
|
||||
"SELECT mission_id FROM colony_missions WHERE mission_id = $1::uuid AND tenant_id = $2",
|
||||
mission_id,
|
||||
tenant_id,
|
||||
)
|
||||
if mission is None:
|
||||
return {}
|
||||
tasks = await conn.fetch(
|
||||
"""
|
||||
SELECT *
|
||||
FROM colony_tasks
|
||||
WHERE mission_id = $1::uuid
|
||||
AND tenant_id = $2
|
||||
ORDER BY created_at ASC
|
||||
""",
|
||||
mission_id,
|
||||
tenant_id,
|
||||
)
|
||||
results = await conn.fetch(
|
||||
"""
|
||||
SELECT *
|
||||
FROM colony_worker_results
|
||||
WHERE mission_id = $1::uuid
|
||||
AND tenant_id = $2
|
||||
ORDER BY created_at ASC
|
||||
""",
|
||||
mission_id,
|
||||
tenant_id,
|
||||
)
|
||||
proposals = await conn.fetch(
|
||||
"""
|
||||
SELECT *
|
||||
FROM colony_writeback_proposals
|
||||
WHERE mission_id = $1::uuid
|
||||
AND tenant_id = $2
|
||||
ORDER BY created_at DESC
|
||||
""",
|
||||
mission_id,
|
||||
tenant_id,
|
||||
)
|
||||
return {
|
||||
"tasks": [_row_dict(row) for row in tasks],
|
||||
"worker_results": [_row_dict(row) for row in results],
|
||||
"writeback_proposals": [_row_dict(row) for row in proposals],
|
||||
}
|
||||
|
||||
async def pending_writeback_proposals(self, mission_id: str, tenant_id: str) -> list[dict[str, Any]]:
|
||||
async with self.pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT *
|
||||
FROM colony_writeback_proposals
|
||||
WHERE mission_id = $1::uuid
|
||||
AND tenant_id = $2
|
||||
AND approval_status = 'pending'
|
||||
ORDER BY created_at ASC
|
||||
""",
|
||||
mission_id,
|
||||
tenant_id,
|
||||
)
|
||||
return [_row_dict(row) for row in rows]
|
||||
|
||||
async def approve_pending_writebacks(self, mission_id: str, tenant_id: str, actor_id: str) -> int:
|
||||
async with self.pool.acquire() as conn:
|
||||
result = await conn.execute(
|
||||
"""
|
||||
UPDATE colony_writeback_proposals
|
||||
SET approval_status = 'approved',
|
||||
approved_by = $3,
|
||||
approved_at = NOW()
|
||||
WHERE mission_id = $1::uuid
|
||||
AND tenant_id = $2
|
||||
AND approval_status = 'pending'
|
||||
""",
|
||||
mission_id,
|
||||
tenant_id,
|
||||
actor_id,
|
||||
)
|
||||
return int(result.rsplit(" ", 1)[-1])
|
||||
|
||||
async def reject_pending_writebacks(self, mission_id: str, tenant_id: str, actor_id: str, reason: str) -> int:
|
||||
async with self.pool.acquire() as conn:
|
||||
result = await conn.execute(
|
||||
"""
|
||||
UPDATE colony_writeback_proposals
|
||||
SET approval_status = 'rejected',
|
||||
rejected_by = $3,
|
||||
rejected_at = NOW(),
|
||||
rejection_reason = $4
|
||||
WHERE mission_id = $1::uuid
|
||||
AND tenant_id = $2
|
||||
AND approval_status = 'pending'
|
||||
""",
|
||||
mission_id,
|
||||
tenant_id,
|
||||
actor_id,
|
||||
reason,
|
||||
)
|
||||
return int(result.rsplit(" ", 1)[-1])
|
||||
|
||||
async def log_event(
|
||||
self,
|
||||
*,
|
||||
mission_id: str | None,
|
||||
tenant_id: str,
|
||||
event_type: str,
|
||||
actor: str | None,
|
||||
detail: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
async with self.pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO colony_event_log (mission_id, tenant_id, event_type, actor, detail, created_at)
|
||||
VALUES ($1::uuid, $2, $3, $4, $5::jsonb, NOW())
|
||||
""",
|
||||
mission_id,
|
||||
tenant_id,
|
||||
event_type,
|
||||
actor,
|
||||
json.dumps(detail or {}),
|
||||
)
|
||||
@@ -6,9 +6,12 @@ import json
|
||||
import os
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
|
||||
PHONEUTILS_AVAILABLE = False
|
||||
try:
|
||||
import phonenumbers
|
||||
@@ -22,6 +25,130 @@ except ImportError:
|
||||
DEFAULT_COUNTRY = os.getenv("COMMS_DEFAULT_COUNTRY_CODE", "91")
|
||||
|
||||
|
||||
class TranscriptionError(RuntimeError):
|
||||
"""Raised when the configured transcription provider cannot produce text."""
|
||||
|
||||
|
||||
async def _read_recording_bytes(recording_url: str) -> tuple[bytes, str, str]:
|
||||
if not recording_url:
|
||||
raise TranscriptionError("recording_url is required.")
|
||||
|
||||
if recording_url.startswith("file://"):
|
||||
path = Path(recording_url[7:]).expanduser()
|
||||
return path.read_bytes(), path.name or "recording.audio", "application/octet-stream"
|
||||
|
||||
local_path = Path(recording_url).expanduser()
|
||||
if local_path.exists():
|
||||
return local_path.read_bytes(), local_path.name or "recording.audio", "application/octet-stream"
|
||||
|
||||
async with httpx.AsyncClient(timeout=60.0, follow_redirects=True) as client:
|
||||
response = await client.get(recording_url)
|
||||
response.raise_for_status()
|
||||
content_type = response.headers.get("content-type", "application/octet-stream")
|
||||
filename = recording_url.rstrip("/").split("/")[-1] or "recording.audio"
|
||||
return response.content, filename, content_type
|
||||
|
||||
|
||||
async def _transcribe_openai(recording_url: str) -> dict[str, Any]:
|
||||
api_key = os.getenv("OPENAI_API_KEY", "").strip()
|
||||
if not api_key:
|
||||
raise TranscriptionError("OPENAI_API_KEY is required for COMMS_TRANSCRIPTION_PROVIDER=openai.")
|
||||
|
||||
audio, filename, content_type = await _read_recording_bytes(recording_url)
|
||||
model = os.getenv("COMMS_OPENAI_TRANSCRIPTION_MODEL", "whisper-1")
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
response = await client.post(
|
||||
"https://api.openai.com/v1/audio/transcriptions",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
data={"model": model, "response_format": "verbose_json"},
|
||||
files={"file": (filename, audio, content_type)},
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
text = (payload.get("text") or "").strip()
|
||||
if not text:
|
||||
raise TranscriptionError("OpenAI transcription response did not include text.")
|
||||
return {
|
||||
"text": text,
|
||||
"provider": "openai",
|
||||
"language": payload.get("language") or "unknown",
|
||||
"segments": payload.get("segments") or [],
|
||||
"raw": payload,
|
||||
}
|
||||
|
||||
|
||||
async def _transcribe_deepgram(recording_url: str) -> dict[str, Any]:
|
||||
api_key = os.getenv("DEEPGRAM_API_KEY", "").strip()
|
||||
if not api_key:
|
||||
raise TranscriptionError("DEEPGRAM_API_KEY is required for COMMS_TRANSCRIPTION_PROVIDER=deepgram.")
|
||||
|
||||
audio, _, content_type = await _read_recording_bytes(recording_url)
|
||||
model = os.getenv("COMMS_DEEPGRAM_MODEL", "nova-2")
|
||||
language = os.getenv("COMMS_TRANSCRIPTION_LANGUAGE", "en")
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
response = await client.post(
|
||||
f"https://api.deepgram.com/v1/listen?model={model}&language={language}&diarize=true&smart_format=true",
|
||||
headers={"Authorization": f"Token {api_key}", "Content-Type": content_type},
|
||||
content=audio,
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
alternative = (
|
||||
payload.get("results", {})
|
||||
.get("channels", [{}])[0]
|
||||
.get("alternatives", [{}])[0]
|
||||
)
|
||||
text = (alternative.get("transcript") or "").strip()
|
||||
if not text:
|
||||
raise TranscriptionError("Deepgram transcription response did not include text.")
|
||||
words = alternative.get("words") or []
|
||||
return {
|
||||
"text": text,
|
||||
"provider": "deepgram",
|
||||
"language": language,
|
||||
"segments": words,
|
||||
"raw": payload,
|
||||
}
|
||||
|
||||
|
||||
async def _transcribe_http_endpoint(recording_url: str) -> dict[str, Any]:
|
||||
endpoint = os.getenv("COMMS_TRANSCRIPTION_ENDPOINT", "").strip()
|
||||
if not endpoint:
|
||||
raise TranscriptionError("COMMS_TRANSCRIPTION_ENDPOINT is required for COMMS_TRANSCRIPTION_PROVIDER=http.")
|
||||
token = os.getenv("COMMS_TRANSCRIPTION_ENDPOINT_TOKEN", "").strip()
|
||||
headers = {"Authorization": f"Bearer {token}"} if token else {}
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
response = await client.post(endpoint, json={"recording_url": recording_url}, headers=headers)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
text = (payload.get("text") or payload.get("transcript") or "").strip()
|
||||
if not text:
|
||||
raise TranscriptionError("HTTP transcription endpoint response did not include text.")
|
||||
return {
|
||||
"text": text,
|
||||
"provider": "http",
|
||||
"language": payload.get("language") or "unknown",
|
||||
"segments": payload.get("segments") or [],
|
||||
"raw": payload,
|
||||
}
|
||||
|
||||
|
||||
async def transcribe_recording(recording_url: str, provider: str | None = None) -> dict[str, Any]:
|
||||
selected = (provider or os.getenv("COMMS_TRANSCRIPTION_PROVIDER", "none")).strip().lower()
|
||||
try:
|
||||
if selected in {"", "none", "disabled"}:
|
||||
raise TranscriptionError("COMMS_TRANSCRIPTION_PROVIDER is not configured.")
|
||||
if selected in {"openai", "whisper"}:
|
||||
return await _transcribe_openai(recording_url)
|
||||
if selected == "deepgram":
|
||||
return await _transcribe_deepgram(recording_url)
|
||||
if selected in {"http", "endpoint", "custom"}:
|
||||
return await _transcribe_http_endpoint(recording_url)
|
||||
raise TranscriptionError(f"Unsupported COMMS_TRANSCRIPTION_PROVIDER '{selected}'.")
|
||||
except httpx.HTTPError as exc:
|
||||
raise TranscriptionError(f"{selected} transcription request failed: {exc}") from exc
|
||||
|
||||
|
||||
def normalize_phone(phone: str, default_region: str = DEFAULT_COUNTRY) -> str | None:
|
||||
"""Return an E.164-like phone number suitable for provider and CRM matching."""
|
||||
if not phone:
|
||||
|
||||
508
backend/services/social_posting.py
Normal file
508
backend/services/social_posting.py
Normal file
@@ -0,0 +1,508 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
import httpx
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SocialPostingConfigurationError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class SocialPostingError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class SocialPlatform(str, Enum):
|
||||
FACEBOOK = "facebook"
|
||||
INSTAGRAM = "instagram"
|
||||
LINKEDIN = "linkedin"
|
||||
TWITTER = "twitter"
|
||||
|
||||
|
||||
class PostStatus(str, Enum):
|
||||
SCHEDULED = "scheduled"
|
||||
PUBLISHING = "publishing"
|
||||
PUBLISHED = "published"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class PostType(str, Enum):
|
||||
IMAGE = "image"
|
||||
VIDEO = "video"
|
||||
CAROUSEL = "carousel"
|
||||
TEXT = "text"
|
||||
LINK = "link"
|
||||
|
||||
|
||||
class PostRequest(BaseModel):
|
||||
platforms: list[SocialPlatform] = Field(..., min_length=1, max_length=8)
|
||||
post_type: PostType = PostType.IMAGE
|
||||
caption: str = Field(..., min_length=1, max_length=4000)
|
||||
hashtags: list[str] = Field(default_factory=list, max_length=40)
|
||||
media_url: str | None = Field(default=None, max_length=2048)
|
||||
media_path: str | None = Field(default=None, max_length=2048)
|
||||
link_url: str | None = Field(default=None, max_length=2048)
|
||||
schedule_time: str | None = Field(default=None, description="ISO-8601 timestamp. Future timestamps persist as scheduled.")
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _env(name: str) -> str:
|
||||
value = os.getenv(name, "").strip()
|
||||
if not value or value.startswith("PLACEHOLDER"):
|
||||
raise SocialPostingConfigurationError(f"{name} is not configured.")
|
||||
return value
|
||||
|
||||
|
||||
def _meta_version() -> str:
|
||||
return os.getenv("META_API_VERSION", "v21.0").strip() or "v21.0"
|
||||
|
||||
|
||||
def _parse_schedule(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _caption_with_hashtags(caption: str, hashtags: list[str]) -> str:
|
||||
cleaned = [tag.strip() for tag in hashtags if tag.strip()]
|
||||
return f"{caption}\n\n{' '.join(cleaned)}" if cleaned else caption
|
||||
|
||||
|
||||
def _serialize_row(row: asyncpg.Record | dict[str, Any]) -> dict[str, Any]:
|
||||
value = dict(row)
|
||||
for key in ("hashtags", "engagement", "platform_response"):
|
||||
if isinstance(value.get(key), str):
|
||||
value[key] = json.loads(value[key])
|
||||
for key in ("created_at", "scheduled_at", "published_at", "updated_at"):
|
||||
if value.get(key) is not None and hasattr(value[key], "isoformat"):
|
||||
value[key] = value[key].isoformat()
|
||||
return value
|
||||
|
||||
|
||||
def _required_env_for_platform(platform: SocialPlatform) -> tuple[str, ...]:
|
||||
if platform == SocialPlatform.FACEBOOK:
|
||||
return ("META_PAGE_ACCESS_TOKEN", "META_PAGE_ID")
|
||||
if platform == SocialPlatform.INSTAGRAM:
|
||||
return ("META_PAGE_ACCESS_TOKEN", "META_INSTAGRAM_BUSINESS_ID")
|
||||
if platform == SocialPlatform.LINKEDIN:
|
||||
return ("LINKEDIN_ACCESS_TOKEN", "LINKEDIN_ORG_ID")
|
||||
if platform == SocialPlatform.TWITTER:
|
||||
return ("TWITTER_BEARER_TOKEN",)
|
||||
raise SocialPostingConfigurationError(f"Unsupported social platform: {platform.value}")
|
||||
|
||||
|
||||
def validate_platform_configuration(platforms: list[SocialPlatform]) -> None:
|
||||
missing: list[str] = []
|
||||
for platform in platforms:
|
||||
for name in _required_env_for_platform(platform):
|
||||
value = os.getenv(name, "").strip()
|
||||
if not value or value.startswith("PLACEHOLDER"):
|
||||
missing.append(name)
|
||||
if missing:
|
||||
joined = ", ".join(sorted(set(missing)))
|
||||
raise SocialPostingConfigurationError(f"Social posting credentials are not configured: {joined}.")
|
||||
|
||||
|
||||
def validate_payload_contract(payload: PostRequest) -> None:
|
||||
if SocialPlatform.INSTAGRAM in payload.platforms and not payload.media_url:
|
||||
raise SocialPostingError("media_url is required for Instagram publishing.")
|
||||
if payload.post_type == PostType.VIDEO and not payload.media_url:
|
||||
raise SocialPostingError("media_url is required for video publishing.")
|
||||
if SocialPlatform.TWITTER in payload.platforms:
|
||||
text = _caption_with_hashtags(payload.caption, payload.hashtags)
|
||||
if payload.link_url:
|
||||
text = f"{text}\n{payload.link_url}"
|
||||
if payload.media_url:
|
||||
text = f"{text}\n{payload.media_url}"
|
||||
if len(text) > 280:
|
||||
raise SocialPostingError("Twitter/X post exceeds 280 characters after links and hashtags.")
|
||||
|
||||
|
||||
async def _insert_post(
|
||||
conn: asyncpg.Connection,
|
||||
*,
|
||||
tenant_id: str,
|
||||
actor_id: str,
|
||||
request_id: str,
|
||||
platform: SocialPlatform,
|
||||
payload: PostRequest,
|
||||
status: PostStatus,
|
||||
scheduled_at: datetime | None,
|
||||
) -> dict[str, Any]:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO catalyst_social_posts (
|
||||
post_id, request_id, tenant_id, actor_id, platform, post_type,
|
||||
caption, hashtags, media_url, media_path, link_url, status,
|
||||
scheduled_at, engagement, created_at, updated_at
|
||||
) VALUES (
|
||||
$1::uuid, $2::uuid, $3, $4, $5, $6,
|
||||
$7, $8::jsonb, $9, $10, $11, $12,
|
||||
$13, '{}'::jsonb, NOW(), NOW()
|
||||
)
|
||||
RETURNING *
|
||||
""",
|
||||
str(uuid.uuid4()),
|
||||
request_id,
|
||||
tenant_id,
|
||||
actor_id,
|
||||
platform.value,
|
||||
payload.post_type.value,
|
||||
payload.caption,
|
||||
json.dumps(payload.hashtags),
|
||||
payload.media_url,
|
||||
payload.media_path,
|
||||
payload.link_url,
|
||||
status.value,
|
||||
scheduled_at,
|
||||
)
|
||||
return _serialize_row(row)
|
||||
|
||||
|
||||
async def _update_post(
|
||||
conn: asyncpg.Connection,
|
||||
*,
|
||||
post_id: str,
|
||||
tenant_id: str,
|
||||
status: PostStatus,
|
||||
platform_post_id: str | None = None,
|
||||
platform_response: dict[str, Any] | None = None,
|
||||
error: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
UPDATE catalyst_social_posts
|
||||
SET status = $3,
|
||||
platform_post_id = COALESCE($4, platform_post_id),
|
||||
platform_response = COALESCE($5::jsonb, platform_response),
|
||||
error = $6,
|
||||
published_at = CASE WHEN $3 = 'published' THEN NOW() ELSE published_at END,
|
||||
updated_at = NOW()
|
||||
WHERE post_id = $1::uuid
|
||||
AND tenant_id = $2
|
||||
RETURNING *
|
||||
""",
|
||||
post_id,
|
||||
tenant_id,
|
||||
status.value,
|
||||
platform_post_id,
|
||||
json.dumps(platform_response) if platform_response is not None else None,
|
||||
error,
|
||||
)
|
||||
if row is None:
|
||||
raise SocialPostingError(f"Social post '{post_id}' not found.")
|
||||
return _serialize_row(row)
|
||||
|
||||
|
||||
class FacebookPublisher:
|
||||
base = "https://graph.facebook.com"
|
||||
|
||||
async def publish(self, post: dict[str, Any]) -> tuple[str, dict[str, Any]]:
|
||||
token = _env("META_PAGE_ACCESS_TOKEN")
|
||||
page_id = _env("META_PAGE_ID")
|
||||
caption = _caption_with_hashtags(post["caption"], post.get("hashtags") or [])
|
||||
post_type = post["post_type"]
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
if post_type == PostType.VIDEO.value:
|
||||
if not post.get("media_url"):
|
||||
raise SocialPostingError("media_url is required for Facebook video posts.")
|
||||
response = await client.post(
|
||||
f"{self.base}/{_meta_version()}/{page_id}/videos",
|
||||
data={"file_url": post["media_url"], "description": caption, "access_token": token},
|
||||
)
|
||||
elif post_type in {PostType.IMAGE.value, PostType.CAROUSEL.value} and post.get("media_url"):
|
||||
response = await client.post(
|
||||
f"{self.base}/{_meta_version()}/{page_id}/photos",
|
||||
data={"url": post["media_url"], "message": caption, "access_token": token},
|
||||
)
|
||||
else:
|
||||
message = f"{caption}\n{post['link_url']}" if post.get("link_url") else caption
|
||||
response = await client.post(
|
||||
f"{self.base}/{_meta_version()}/{page_id}/feed",
|
||||
data={"message": message, "access_token": token},
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
raise SocialPostingError(f"Facebook publish failed: {response.text}")
|
||||
data = response.json()
|
||||
return data.get("id", data.get("post_id", "")), data
|
||||
|
||||
|
||||
class InstagramPublisher:
|
||||
base = "https://graph.facebook.com"
|
||||
|
||||
async def publish(self, post: dict[str, Any]) -> tuple[str, dict[str, Any]]:
|
||||
token = _env("META_PAGE_ACCESS_TOKEN")
|
||||
instagram_id = _env("META_INSTAGRAM_BUSINESS_ID")
|
||||
if not post.get("media_url"):
|
||||
raise SocialPostingError("media_url is required for Instagram posts.")
|
||||
caption = _caption_with_hashtags(post["caption"], post.get("hashtags") or [])
|
||||
async with httpx.AsyncClient(timeout=180.0) as client:
|
||||
create_payload: dict[str, Any] = {
|
||||
"caption": caption,
|
||||
"access_token": token,
|
||||
}
|
||||
if post["post_type"] == PostType.VIDEO.value:
|
||||
create_payload.update({"video_url": post["media_url"], "media_type": "REELS"})
|
||||
else:
|
||||
create_payload["image_url"] = post["media_url"]
|
||||
created = await client.post(
|
||||
f"{self.base}/{_meta_version()}/{instagram_id}/media",
|
||||
data=create_payload,
|
||||
)
|
||||
if created.status_code >= 400:
|
||||
raise SocialPostingError(f"Instagram container creation failed: {created.text}")
|
||||
container_id = created.json().get("id")
|
||||
if not container_id:
|
||||
raise SocialPostingError("Instagram did not return a media container id.")
|
||||
published = await client.post(
|
||||
f"{self.base}/{_meta_version()}/{instagram_id}/media_publish",
|
||||
data={"creation_id": container_id, "access_token": token},
|
||||
)
|
||||
if published.status_code >= 400:
|
||||
raise SocialPostingError(f"Instagram publish failed: {published.text}")
|
||||
data = published.json()
|
||||
return data.get("id", ""), {"container": created.json(), "publish": data}
|
||||
|
||||
|
||||
class LinkedInPublisher:
|
||||
base = "https://api.linkedin.com/v2"
|
||||
|
||||
async def publish(self, post: dict[str, Any]) -> tuple[str, dict[str, Any]]:
|
||||
token = _env("LINKEDIN_ACCESS_TOKEN")
|
||||
org_id = _env("LINKEDIN_ORG_ID")
|
||||
caption = _caption_with_hashtags(post["caption"], post.get("hashtags") or [])
|
||||
if post.get("link_url"):
|
||||
caption = f"{caption}\n{post['link_url']}"
|
||||
if post.get("media_url"):
|
||||
caption = f"{caption}\n{post['media_url']}"
|
||||
payload = {
|
||||
"author": f"urn:li:organization:{org_id}",
|
||||
"lifecycleState": "PUBLISHED",
|
||||
"specificContent": {
|
||||
"com.linkedin.ugc.ShareContent": {
|
||||
"shareCommentary": {"text": caption},
|
||||
"shareMediaCategory": "NONE",
|
||||
}
|
||||
},
|
||||
"visibility": {"com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"},
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.post(
|
||||
f"{self.base}/ugcPosts",
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
"X-Restli-Protocol-Version": "2.0.0",
|
||||
},
|
||||
json=payload,
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
raise SocialPostingError(f"LinkedIn publish failed: {response.text}")
|
||||
data = response.json()
|
||||
return data.get("id", ""), data
|
||||
|
||||
|
||||
class TwitterPublisher:
|
||||
base = "https://api.twitter.com/2"
|
||||
|
||||
async def publish(self, post: dict[str, Any]) -> tuple[str, dict[str, Any]]:
|
||||
token = _env("TWITTER_BEARER_TOKEN")
|
||||
text = _caption_with_hashtags(post["caption"], post.get("hashtags") or [])
|
||||
if post.get("link_url"):
|
||||
text = f"{text}\n{post['link_url']}"
|
||||
if post.get("media_url"):
|
||||
text = f"{text}\n{post['media_url']}"
|
||||
if len(text) > 280:
|
||||
raise SocialPostingError("Twitter/X post exceeds 280 characters after links and hashtags.")
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.post(
|
||||
f"{self.base}/tweets",
|
||||
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
||||
json={"text": text},
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
raise SocialPostingError(f"Twitter/X publish failed: {response.text}")
|
||||
data = response.json()
|
||||
return data.get("data", {}).get("id", ""), data
|
||||
|
||||
|
||||
_PUBLISHERS = {
|
||||
SocialPlatform.FACEBOOK.value: FacebookPublisher(),
|
||||
SocialPlatform.INSTAGRAM.value: InstagramPublisher(),
|
||||
SocialPlatform.LINKEDIN.value: LinkedInPublisher(),
|
||||
SocialPlatform.TWITTER.value: TwitterPublisher(),
|
||||
}
|
||||
|
||||
|
||||
async def _publish_post(conn: asyncpg.Connection, post: dict[str, Any]) -> dict[str, Any]:
|
||||
publishing = await _update_post(
|
||||
conn,
|
||||
post_id=post["post_id"],
|
||||
tenant_id=post["tenant_id"],
|
||||
status=PostStatus.PUBLISHING,
|
||||
)
|
||||
publisher = _PUBLISHERS.get(publishing["platform"])
|
||||
if publisher is None:
|
||||
raise SocialPostingError(f"Unsupported social platform: {publishing['platform']}")
|
||||
platform_post_id, platform_response = await publisher.publish(publishing)
|
||||
return await _update_post(
|
||||
conn,
|
||||
post_id=publishing["post_id"],
|
||||
tenant_id=publishing["tenant_id"],
|
||||
status=PostStatus.PUBLISHED,
|
||||
platform_post_id=platform_post_id,
|
||||
platform_response=platform_response,
|
||||
)
|
||||
|
||||
|
||||
async def publish_content(
|
||||
*,
|
||||
pool: asyncpg.Pool,
|
||||
tenant_id: str,
|
||||
actor_id: str,
|
||||
payload: PostRequest,
|
||||
) -> dict[str, Any]:
|
||||
validate_platform_configuration(payload.platforms)
|
||||
validate_payload_contract(payload)
|
||||
request_id = str(uuid.uuid4())
|
||||
scheduled_at = _parse_schedule(payload.schedule_time)
|
||||
posts: list[dict[str, Any]] = []
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
for platform in payload.platforms:
|
||||
post = await _insert_post(
|
||||
conn,
|
||||
tenant_id=tenant_id,
|
||||
actor_id=actor_id,
|
||||
request_id=request_id,
|
||||
platform=platform,
|
||||
payload=payload,
|
||||
status=PostStatus.SCHEDULED if scheduled_at and scheduled_at > _utcnow() else PostStatus.PUBLISHING,
|
||||
scheduled_at=scheduled_at,
|
||||
)
|
||||
posts.append(post)
|
||||
|
||||
if scheduled_at and scheduled_at > _utcnow():
|
||||
return {
|
||||
"request_id": request_id,
|
||||
"total": len(posts),
|
||||
"published": 0,
|
||||
"scheduled": len(posts),
|
||||
"failed": 0,
|
||||
"posts": posts,
|
||||
}
|
||||
|
||||
published: list[dict[str, Any]] = []
|
||||
failed: list[dict[str, Any]] = []
|
||||
async with pool.acquire() as conn:
|
||||
for post in posts:
|
||||
try:
|
||||
published.append(await _publish_post(conn, post))
|
||||
except (SocialPostingConfigurationError, SocialPostingError, httpx.HTTPError) as exc:
|
||||
failed.append(
|
||||
await _update_post(
|
||||
conn,
|
||||
post_id=post["post_id"],
|
||||
tenant_id=tenant_id,
|
||||
status=PostStatus.FAILED,
|
||||
error=str(exc),
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"request_id": request_id,
|
||||
"total": len(posts),
|
||||
"published": len(published),
|
||||
"scheduled": 0,
|
||||
"failed": len(failed),
|
||||
"posts": published + failed,
|
||||
}
|
||||
|
||||
|
||||
async def get_post(*, pool: asyncpg.Pool, tenant_id: str, post_id: str) -> dict[str, Any] | None:
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT * FROM catalyst_social_posts WHERE post_id = $1::uuid AND tenant_id = $2",
|
||||
post_id,
|
||||
tenant_id,
|
||||
)
|
||||
return _serialize_row(row) if row else None
|
||||
|
||||
|
||||
async def list_posts(
|
||||
*,
|
||||
pool: asyncpg.Pool,
|
||||
tenant_id: str,
|
||||
platform: SocialPlatform | None = None,
|
||||
status: PostStatus | None = None,
|
||||
limit: int = 50,
|
||||
) -> list[dict[str, Any]]:
|
||||
clauses = ["tenant_id = $1"]
|
||||
params: list[Any] = [tenant_id]
|
||||
if platform:
|
||||
params.append(platform.value)
|
||||
clauses.append(f"platform = ${len(params)}")
|
||||
if status:
|
||||
params.append(status.value)
|
||||
clauses.append(f"status = ${len(params)}")
|
||||
params.append(limit)
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT *
|
||||
FROM catalyst_social_posts
|
||||
WHERE {' AND '.join(clauses)}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ${len(params)}
|
||||
""",
|
||||
*params,
|
||||
)
|
||||
return [_serialize_row(row) for row in rows]
|
||||
|
||||
|
||||
async def publish_due_scheduled(*, pool: asyncpg.Pool, tenant_id: str, limit: int = 20) -> dict[str, Any]:
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT *
|
||||
FROM catalyst_social_posts
|
||||
WHERE tenant_id = $1
|
||||
AND status = 'scheduled'
|
||||
AND scheduled_at <= NOW()
|
||||
ORDER BY scheduled_at ASC
|
||||
LIMIT $2
|
||||
""",
|
||||
tenant_id,
|
||||
limit,
|
||||
)
|
||||
published: list[dict[str, Any]] = []
|
||||
failed: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
post = _serialize_row(row)
|
||||
try:
|
||||
published.append(await _publish_post(conn, post))
|
||||
except (SocialPostingConfigurationError, SocialPostingError, httpx.HTTPError) as exc:
|
||||
failed.append(
|
||||
await _update_post(
|
||||
conn,
|
||||
post_id=post["post_id"],
|
||||
tenant_id=tenant_id,
|
||||
status=PostStatus.FAILED,
|
||||
error=str(exc),
|
||||
)
|
||||
)
|
||||
return {"published": len(published), "failed": len(failed), "posts": published + failed}
|
||||
@@ -108,6 +108,30 @@ def test_canonical_crm_import_upload_requires_authentication() -> None:
|
||||
assert response.json()["detail"] == "Missing or malformed Authorization header."
|
||||
|
||||
|
||||
def test_canonical_crm_vocabularies_require_authentication() -> None:
|
||||
client = _build_app(authenticated=False)
|
||||
|
||||
response = client.get("/api/crm/vocabularies")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == "Missing or malformed Authorization header."
|
||||
|
||||
|
||||
def test_canonical_crm_vocabularies_are_backend_owned() -> None:
|
||||
client = _build_app(authenticated=True)
|
||||
|
||||
response = client.get("/api/crm/vocabularies")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()["data"]
|
||||
assert payload["lead_statuses"][0]["value"] == routes_crm_imports.CANONICAL_LEAD_STAGES[0]
|
||||
assert payload["opportunity_stages"][0]["value"] == routes_crm_imports.CANONICAL_OPPORTUNITY_STAGES[0]
|
||||
assert {policy["value"] for policy in payload["import_duplicate_policies"]} == set(
|
||||
routes_crm_imports.IMPORT_DUPLICATE_POLICIES
|
||||
)
|
||||
assert payload["dream_weaver_room_types"][0]["icon"]
|
||||
|
||||
|
||||
def test_canonical_crm_contacts_can_be_read_when_authenticated(monkeypatch) -> None:
|
||||
client = _build_app(authenticated=True)
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("VELOCITY_JWT_SECRET", "test-secret")
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.api.routes_catalyst import router
|
||||
from backend.auth.dependencies import UserPrincipal, get_current_user
|
||||
from backend.services.ad_network_service import BidAction, Platform
|
||||
|
||||
|
||||
@@ -18,6 +23,18 @@ def _build_client() -> TestClient:
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _build_authed_client() -> TestClient:
|
||||
app = FastAPI()
|
||||
app.state.db_pool = object()
|
||||
app.dependency_overrides[get_current_user] = lambda: UserPrincipal(
|
||||
user_id="sayan",
|
||||
role="ADMIN",
|
||||
tenant_id="tenant-root",
|
||||
)
|
||||
app.include_router(router, prefix="/api/catalyst")
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_catalyst_campaigns_and_google_budget_routes(monkeypatch) -> None:
|
||||
client = _build_client()
|
||||
|
||||
@@ -92,3 +109,51 @@ def test_catalyst_campaigns_and_google_budget_routes(monkeypatch) -> None:
|
||||
)
|
||||
assert bid.status_code == 200
|
||||
assert bid.json()["data"]["new_strategy"] == "TARGET_ROAS"
|
||||
|
||||
|
||||
def test_catalyst_social_publish_route_is_tenant_scoped(monkeypatch) -> None:
|
||||
async def fake_publish_content(*, pool, tenant_id, actor_id, payload):
|
||||
assert pool is not None
|
||||
assert tenant_id == "tenant-root"
|
||||
assert actor_id == "sayan"
|
||||
assert payload.platforms[0].value == "facebook"
|
||||
return {
|
||||
"request_id": "req-1",
|
||||
"total": 1,
|
||||
"published": 1,
|
||||
"scheduled": 0,
|
||||
"failed": 0,
|
||||
"posts": [{"post_id": "post-1", "platform": "facebook", "status": "published"}],
|
||||
}
|
||||
|
||||
monkeypatch.setattr("backend.api.routes_catalyst.publish_content", fake_publish_content)
|
||||
|
||||
response = _build_authed_client().post(
|
||||
"/api/catalyst/publish",
|
||||
json={
|
||||
"platforms": ["facebook"],
|
||||
"post_type": "image",
|
||||
"caption": "New waterfront inventory is ready.",
|
||||
"media_url": "https://assets.example.com/post.jpg",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json()["data"]["published"] == 1
|
||||
|
||||
|
||||
def test_catalyst_scheduled_posts_route_filters_scheduled_status(monkeypatch) -> None:
|
||||
async def fake_list_posts(*, pool, tenant_id, platform=None, status=None, limit=50):
|
||||
assert pool is not None
|
||||
assert tenant_id == "tenant-root"
|
||||
assert platform is None
|
||||
assert status.value == "scheduled"
|
||||
assert limit == 25
|
||||
return [{"post_id": "post-2", "platform": "linkedin", "status": "scheduled"}]
|
||||
|
||||
monkeypatch.setattr("backend.api.routes_catalyst.list_posts", fake_list_posts)
|
||||
|
||||
response = _build_authed_client().get("/api/catalyst/scheduled?limit=25")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["meta"]["count"] == 1
|
||||
|
||||
99
backend/tests/test_colony_routes.py
Normal file
99
backend/tests/test_colony_routes.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
os.environ.setdefault("VELOCITY_JWT_SECRET", "test-secret")
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.api import routes_colony
|
||||
from backend.auth.dependencies import UserPrincipal, get_current_user
|
||||
|
||||
|
||||
def _mission_row(mission: dict[str, Any], *, status: str = "pending") -> dict[str, Any]:
|
||||
now = datetime(2026, 5, 3, tzinfo=timezone.utc)
|
||||
return {
|
||||
**mission,
|
||||
"status": status,
|
||||
"review_status": None,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"completed_at": None,
|
||||
}
|
||||
|
||||
|
||||
def _build_client() -> TestClient:
|
||||
app = FastAPI()
|
||||
app.state.db_pool = object()
|
||||
app.dependency_overrides[get_current_user] = lambda: UserPrincipal(
|
||||
user_id="sayan",
|
||||
role="ADMIN",
|
||||
tenant_id="tenant-root",
|
||||
)
|
||||
app.include_router(routes_colony.router, prefix="/api/colony")
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_colony_create_mission_persists_and_dispatches(monkeypatch) -> None:
|
||||
stored: dict[str, Any] = {}
|
||||
events: list[str] = []
|
||||
|
||||
class FakeRepo:
|
||||
def __init__(self, _pool) -> None:
|
||||
pass
|
||||
|
||||
async def create_mission(self, mission: dict[str, Any]) -> dict[str, Any]:
|
||||
stored.update(mission)
|
||||
return _mission_row(mission)
|
||||
|
||||
async def update_status(self, mission_id: str, tenant_id: str, status: str, **_kwargs) -> dict[str, Any]:
|
||||
assert mission_id == stored["mission_id"]
|
||||
assert tenant_id == "tenant-root"
|
||||
return _mission_row(stored, status=status)
|
||||
|
||||
async def log_event(self, **kwargs) -> None:
|
||||
events.append(kwargs["event_type"])
|
||||
|
||||
class FakeGateway:
|
||||
async def dispatch_mission(self, mission: dict[str, Any]) -> dict[str, Any]:
|
||||
assert mission["tenant_id"] == "tenant-root"
|
||||
assert mission["actor_id"] == "sayan"
|
||||
return {"accepted": True, "remote_mission_id": mission["mission_id"]}
|
||||
|
||||
monkeypatch.setattr(routes_colony, "ColonyRepository", FakeRepo)
|
||||
monkeypatch.setattr(routes_colony, "ColonyGateway", FakeGateway)
|
||||
|
||||
response = _build_client().post(
|
||||
"/api/colony/missions",
|
||||
json={
|
||||
"mission_type": "oracle_advisory",
|
||||
"user_goal": "Compare Palm Jumeirah leads and recommend the next broker action.",
|
||||
"context_refs": {"lead_id": "lead-1"},
|
||||
"requested_outputs": ["summary", "writeback_proposals"],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
body = response.json()["data"]
|
||||
assert body["tenant_id"] == "tenant-root"
|
||||
assert body["actor_id"] == "sayan"
|
||||
assert body["status"] == "queued"
|
||||
assert body["dispatch"]["accepted"] is True
|
||||
assert events == ["mission_created", "mission_dispatched"]
|
||||
|
||||
|
||||
def test_colony_create_mission_requires_configured_orchestrator(monkeypatch) -> None:
|
||||
monkeypatch.delenv("COLONY_SERVICE_URL", raising=False)
|
||||
response = _build_client().post(
|
||||
"/api/colony/missions",
|
||||
json={
|
||||
"mission_type": "catalyst_strategy_brief",
|
||||
"user_goal": "Prepare a launch brief.",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert "COLONY_SERVICE_URL" in response.json()["detail"]
|
||||
Reference in New Issue
Block a user