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:
2026-05-03 18:30:38 +05:30
parent 59d398abc3
commit eeb684b46c
86 changed files with 20349 additions and 1655 deletions

View File

@@ -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

View 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=

View File

@@ -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 = {

View File

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

View 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),
},
}

View File

@@ -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"],
}

View File

@@ -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]]:

View File

@@ -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,

View File

@@ -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"])

View File

@@ -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"])

View File

@@ -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);

View File

@@ -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 ───────────────────────────────────────────────────

View File

@@ -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")

View 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 ""

View File

@@ -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(
"""

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

View 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 {}),
)

View File

@@ -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:

View 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}

View File

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

View File

@@ -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

View 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"]