feat: Ipad app production readiness, Colony orchestration, Social posting

This commit is contained in:
Sayan Datta
2026-05-03 18:28:04 +05:30
parent acfc602157
commit 6c93e31741
86 changed files with 20349 additions and 1655 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,98 @@
I audited iOS/velocity-ipad/velocity for mock/demo/static/fallback data paths. Good news: I did not find hard-coded fake CRM people, fake properties, fake opportunities, fake calendar events, or fake comms threads being rendered as normal production data. The app is mostly live-backed.
That said, there are still several hard-coded or locally synthesized data paths you should decide whether to move fully behind backend/database contracts.
Production-Risk Mock Or Synthetic Data
SimulatorSunOverlayView.swift (line 8)
Uses fake simulator-only location: San Francisco 37.7749, -122.4194.
Uses mock heading: 0.
Wrapped in #if targetEnvironment(simulator), so it should not run on physical iPad, but it is still mock data in app code.
InventoryView.swift (line 941)
If Building.usdz / Building.scn fails to load, Dollhouse falls back to a procedural synthetic building.
The fallback creates hard-coded rooms, walls, colors, and dimensions at InventoryView.swift (line 975).
For production, this should probably fail closed or fetch a real model reference from backend inventory metadata.
Building.usda (line 1) and Building.usdz
Bundled local 3D building asset with hard-coded cube geometry: Podium, TowerA, TowerB, AmenityDeck, Courtyard.
This is not backend/database-backed property inventory. It is a static app asset.
Frontend-Derived Fallback Data
VelocityAPIClient.swift (line 1538)
VelocityClient360DTO.minimal(from:) fabricates a Client 360 snapshot from a contact when richer Client 360 data is unavailable.
It creates local QD overview fields, empty opportunities/interactions/tasks/interests, and note "Derived from the CRM client-data endpoint."
VelocityAPIClient.swift (line 2725)
If Client 360 decode fails with invalidResponse, the app fetches contacts and builds the minimal local snapshot instead of failing.
VelocityAPIClient.swift (line 1905)
If backend gives QD scores but no recommended actions, the app generates: Review {scoreType} score at {displayScore}.
This is local advisory text, not backend intelligence.
AppStore.swift (line 873)
Dashboard metrics fall back to locally computed canonicalDashboardMetrics(...) if /api/dashboard/metrics fails.
The app computes lead count, whale count, property count, today calendar count, pending insights, etc. locally at AppStore.swift (line 902).
AppStore.swift (line 857)
If contact fetch returns 404, app reuses cached contacts instead of treating backend as source-of-truth unavailable.
AppStore.swift (line 869)
Several failed backend calls silently fall back to empty arrays: kanban, opportunities, properties.
This can make missing backend data look like “zero production data.”
Local Offline Data That Becomes Temporary UI Truth
10. AppStore.swift (line 607)
Offline calendar create generates local IDs like local-{UUID} and local createdAt.
The event is merged into UI before backend confirmation.
AppStore.swift (line 436)
If task mutation happens offline and the task cannot be resolved, app fabricates a local task title: "Queued CRM task" and default priority "normal".
OfflineReplayStore.swift (line 16)
App stores offline replay mutations in local Core Data OfflineReplay.sqlite.
This is valid offline architecture, but if you require strict backend-only truth, this should be treated as a write queue only and clearly marked as “pending sync.”
Hard-Coded Business Vocabularies
13. ClientsView.swift (line 35)
Hard-coded lead statuses: new, contacted, qualified, site_visit_scheduled, etc.
ClientsView.swift (line 48)
Hard-coded urgency values: low, medium, high, critical.
ClientsView.swift (line 49)
Hard-coded buyer types: end_user, hni_end_user, nri_investor, family_office, etc.
ClientsView.swift (line 59)
Hard-coded task priorities: low, normal, high, urgent.
OracleView.swift (line 1001)
Hard-coded canonical lead stages.
OracleView.swift (line 1016)
Hard-coded opportunity stages: prospect, qualified, proposal, site_visit, etc.
ImportsView.swift (line 26)
Hard-coded duplicate policies: create_new, update_existing, skip_duplicate.
InventoryView.swift (line 501)
Hard-coded Dream Weaver room types: bedroom, living room, bathroom, kitchen, etc.
These are sent to the backend as room_type.
Hard-Coded Defaults Affecting Created Backend Data
21. VelocityAPIClient.swift (line 2579)
Communications task creation defaults priority to "normal".
VelocityAPIClient.swift (line 2969)
CSV import upload defaults source_system to "ipad_csv_upload".
CalendarView.swift (line 1118)
Calendar create adds local metadata: created_from = ipad_calendar, surface = velocity_ipad.
Config And Environment Defaults
24. SessionConfiguration.swift (line 16)
Default endpoint prompt/value: https://velocity.desineuron.in/api.
SessionConfiguration.swift (line 17)
Default Dream Weaver endpoint: https://dreamweaver.desineuron.in.
SessionConfigurationPanel.swift (line 74)
Placeholder operator email: operator@desineuron.in.
Verdict
The highest-priority removals for true production purity are:
Remove Dollhouse procedural fallback or move model selection to backend inventory metadata.
Replace hard-coded CRM vocabularies/stages with /api/crm/vocabularies or similar.
Remove Client 360 minimal fallback so malformed/missing backend data fails visibly.
Stop silently converting failed backend reads into empty arrays.
Keep offline replay only as a pending-write queue, not as “truth” without clear pending-sync labeling.
Replace simulator fake Sunseeker data with unavailable-state only, if simulator mock paths must be totally absent.

25
app/dist/index.html vendored
View File

@@ -1,16 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Velocity WebOS</title>
<script type="module" crossorigin src="./assets/index-C0KOan5Q.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CrH2wIGN.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Velocity WebOS</title>
<script type="module" crossorigin src="./assets/index-DqpQquIA.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-80rkGqUG.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -22,11 +22,13 @@ import {
X,
MessageSquarePlus,
Sparkles,
Zap,
Brain,
PanelLeft,
type LucideIcon,
} from 'lucide-react';
import { Input } from '@/components/ui/input';
import type { CanvasPage, CanvasPageRevision, MergeRequest, UserProfile } from '@/oracle/types/canvas';
import type { CanvasPage, CanvasPageRevision, MergeRequest, OracleExecutionMode, UserProfile } from '@/oracle/types/canvas';
import type { ComponentRenderContext } from '@/oracle/components/ComponentRegistry';
import { useOraclePage } from '@/oracle/hooks/useOraclePage';
import { useOracleExecution } from '@/oracle/hooks/useOracleExecution';
@@ -60,6 +62,12 @@ const PROMPT_MODES: Array<{ view: string; label: string; samplePrompt: string; i
{ view: 'kpi', label: 'KPI Summary', samplePrompt: 'Give me a KPI summary of total pipeline value today.', icon: BarChart2 },
];
const EXECUTION_MODES: Array<{ value: OracleExecutionMode; label: string; icon: LucideIcon }> = [
{ value: 'auto', label: 'Auto', icon: Sparkles },
{ value: 'fast', label: 'Fast', icon: Zap },
{ value: 'thinking', label: 'Thinking', icon: Brain },
];
const BASE_CTX: ComponentRenderContext = {
tenantId: '',
actorRole: 'sales_director',
@@ -116,6 +124,7 @@ export default function OraclePage() {
const [prompt, setPrompt] = useState('');
const [selectedMode, setSelectedMode] = useState(PROMPT_MODES[0]);
const [executionMode, setExecutionMode] = useState<OracleExecutionMode>('auto');
const [viewDropOpen, setViewDropOpen] = useState(false);
const [listening, setListening] = useState(false);
const [railOpen, setRailOpen] = useState(false);
@@ -208,6 +217,7 @@ export default function OraclePage() {
prompt: clean,
tenantId: me.tenantId,
actorId: me.userId,
executionMode,
placementMode: me.canvasPreferences.defaultPlacementMode,
conversationContext: history.flatMap((entry) => [
{ role: 'user' as const, content: entry.execution.prompt },
@@ -227,7 +237,7 @@ export default function OraclePage() {
}
await Promise.all([refresh(), loadCanvasSessions()]);
}, [prompt, inFlight, page, me, submit, history, applyRevision, refresh, loadCanvasSessions]);
}, [prompt, inFlight, page, me, submit, history, executionMode, applyRevision, refresh, loadCanvasSessions]);
const handleMic = useCallback(() => {
const browserWindow = window as Window & {
@@ -736,6 +746,31 @@ export default function OraclePage() {
{viewDropOpen && <div className="fixed inset-0 z-[998]" onClick={() => setViewDropOpen(false)} />}
</div>
<div
className="flex items-center rounded-full p-0.5"
style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.09)' }}
>
{EXECUTION_MODES.map((mode) => {
const active = executionMode === mode.value;
return (
<button
key={mode.value}
type="button"
onClick={() => setExecutionMode(mode.value)}
className="flex h-7 items-center gap-1.5 rounded-full px-2.5 text-xs transition-all"
style={{
background: active ? 'rgba(59,130,246,0.18)' : 'transparent',
color: active ? '#bfdbfe' : 'rgba(255,255,255,0.48)',
}}
title={mode.value === 'auto' ? 'Let Oracle choose the route' : mode.value === 'fast' ? 'Use Oracle directly' : 'Use Colony orchestration'}
>
<mode.icon className="h-3 w-3" />
<span className={mode.value === 'thinking' ? 'hidden sm:inline' : ''}>{mode.label}</span>
</button>
);
})}
</div>
<button
type="button"
id="oracle-rail-toggle"

View File

@@ -2,7 +2,7 @@
* useOracleExecution — manages prompt submission and durable execution history.
*/
import { useState, useCallback, useRef } from 'react';
import type { PromptExecution, CanvasComponent, PlacementMode } from '../types/canvas';
import type { PromptExecution, CanvasComponent, PlacementMode, OracleExecutionMode } from '../types/canvas';
import { submitPrompt } from '../lib/oracleApiClient';
export interface ExecutionEntry {
@@ -20,6 +20,7 @@ export interface OracleExecutionState {
prompt: string;
tenantId: string;
actorId: string;
executionMode?: OracleExecutionMode;
placementMode?: PlacementMode;
conversationContext?: Array<{ role: 'user' | 'assistant'; content: string }>;
onExecutionCommitted?: (commit: {
@@ -45,6 +46,7 @@ export function useOracleExecution(): OracleExecutionState {
prompt,
tenantId,
actorId,
executionMode = 'auto',
placementMode = 'append_after_last_visible_component',
conversationContext = [],
onExecutionCommitted,
@@ -54,6 +56,7 @@ export function useOracleExecution(): OracleExecutionState {
prompt: string;
tenantId: string;
actorId: string;
executionMode?: OracleExecutionMode;
placementMode?: PlacementMode;
conversationContext?: Array<{ role: 'user' | 'assistant'; content: string }>;
onExecutionCommitted?: (commit: {
@@ -73,10 +76,11 @@ export function useOracleExecution(): OracleExecutionState {
prompt,
intentClass: 'analytical',
status: 'planning',
modelRuntime: 'oracle_runtime',
modelRuntime: executionMode === 'thinking' ? 'colony_orchestrator' : 'oracle_runtime',
semanticModelVersion: 'oracle_semantic_v2026_04_08_01',
warnings: [],
createdAt: now,
executionMode,
};
setInFlight(optimistic);
@@ -91,6 +95,7 @@ export function useOracleExecution(): OracleExecutionState {
prompt,
conversationContext,
placementMode,
executionMode,
});
const completed: PromptExecution = {
@@ -100,6 +105,9 @@ export function useOracleExecution(): OracleExecutionState {
summary: response.summary,
warnings: response.warnings,
componentsCreated: response.componentsCreated,
executionMode: response.executionMode ?? executionMode,
resolvedMode: response.resolvedMode,
colonyMissionId: response.colonyMissionId,
completedAt: new Date().toISOString(),
};

View File

@@ -50,6 +50,8 @@ export type ExecutionStatus =
| 'failed'
| 'clarification_required';
export type OracleExecutionMode = 'auto' | 'fast' | 'thinking';
export type PageType = 'main' | 'fork';
export type ForkStatus = 'active' | 'merged' | 'closed';
@@ -280,6 +282,9 @@ export interface PromptExecution {
componentsCreated?: string[];
createdAt: string;
completedAt?: string;
executionMode?: OracleExecutionMode;
resolvedMode?: 'fast' | 'thinking';
colonyMissionId?: string;
}
export interface ComponentTemplate {
@@ -383,6 +388,7 @@ export interface PromptSubmitRequest {
prompt: string;
conversationContext?: Array<{ role: 'user' | 'assistant'; content: string }>;
placementMode?: PlacementMode;
executionMode?: OracleExecutionMode;
}
export interface PromptSubmitResponse {
@@ -395,6 +401,9 @@ export interface PromptSubmitResponse {
components: CanvasComponent[];
summary: string;
warnings: string[];
executionMode?: OracleExecutionMode;
resolvedMode?: 'fast' | 'thinking';
colonyMissionId?: string;
}
export interface CanvasPageRevision {

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

View File

@@ -0,0 +1,8 @@
app_identifier("com.desineuron.velocity.ipad")
apple_id(ENV.fetch("FASTLANE_APPLE_ID", ""))
team_id(ENV.fetch("FASTLANE_TEAM_ID", "L29922NHD9"))
itc_team_id(ENV["FASTLANE_ITC_TEAM_ID"]) if ENV["FASTLANE_ITC_TEAM_ID"]
for_platform :ios do
app_identifier("com.desineuron.velocity.ipad")
end

View File

@@ -0,0 +1,84 @@
# App Store Connect metadata for Project Velocity iPad.
# Fastlane's TestFlight upload action consumes the metadata module below, while
# deliver can still use the App Store Connect and export-compliance settings.
module VelocityAppStoreConnectMetadata
module_function
def testflight_groups
ENV.fetch("FASTLANE_TESTFLIGHT_GROUPS", "Velocity Investor Demo")
.split(",")
.map(&:strip)
.reject(&:empty?)
end
def beta_app_review_info
{
contact_first_name: ENV.fetch("FASTLANE_BETA_CONTACT_FIRST_NAME", "Sayan"),
contact_last_name: ENV.fetch("FASTLANE_BETA_CONTACT_LAST_NAME", "Desi Neuron"),
contact_phone: ENV.fetch("FASTLANE_BETA_CONTACT_PHONE", "+919999999999"),
contact_email: ENV.fetch("FASTLANE_BETA_CONTACT_EMAIL", "ops@desineuron.in"),
demo_account_name: ENV.fetch("FASTLANE_BETA_DEMO_ACCOUNT", "demo@desineuron.in"),
demo_account_password: ENV.fetch("FASTLANE_BETA_DEMO_PASSWORD", ""),
notes: ENV.fetch(
"FASTLANE_BETA_REVIEW_NOTES",
"Please use the supplied demo account to validate Dashboard, Calendar, CRM Imports, Client 360, Oracle, Inventory, Sentinel, Communications, and Dream Weaver health states. The app is intended for managed iPad deployments for enterprise real estate sales teams."
)
}
end
def localized_build_info
{
"default" => {
whats_new: ENV.fetch(
"FASTLANE_CHANGELOG",
"Production candidate for investor demo validation with CRM, Oracle, Sentinel, Inventory, Communications, Calendar, and Dream Weaver workflows."
)
}
}
end
def localized_app_info
{
"default" => {
feedback_email: ENV.fetch("FASTLANE_BETA_FEEDBACK_EMAIL", "ops@desineuron.in"),
marketing_url: ENV.fetch("FASTLANE_MARKETING_URL", "https://velocity.desineuron.in"),
privacy_policy_url: ENV.fetch("FASTLANE_PRIVACY_URL", "https://velocity.desineuron.in/privacy"),
description: ENV.fetch(
"FASTLANE_BETA_DESCRIPTION",
"Velocity iPad is the native CRM, Oracle, Sentinel, Inventory, and Dream Weaver command center for enterprise real estate operators."
)
}
}
end
def submission_information
{
export_compliance_uses_encryption: false,
export_compliance_contains_third_party_cryptography: false,
export_compliance_contains_proprietary_cryptography: false,
export_compliance_available_on_french_store: true,
export_compliance_ccat_file: false,
add_id_info_uses_idfa: false
}
end
end
def velocity_fastlane_config(method_name, *args)
send(method_name, *args) if respond_to?(method_name)
end
velocity_fastlane_config(:app_identifier, "com.desineuron.velocity.ipad")
velocity_fastlane_config(:username, ENV.fetch("FASTLANE_APPLE_ID", ""))
velocity_fastlane_config(:team_id, ENV.fetch("FASTLANE_TEAM_ID", "L29922NHD9"))
velocity_fastlane_config(:itc_team_id, ENV["FASTLANE_ITC_TEAM_ID"]) if ENV["FASTLANE_ITC_TEAM_ID"]
velocity_fastlane_config(:app_platform, "ios")
velocity_fastlane_config(:skip_screenshots, true)
velocity_fastlane_config(:skip_binary_upload, true)
velocity_fastlane_config(:skip_app_version_update, true)
velocity_fastlane_config(:force, true)
velocity_fastlane_config(:run_precheck_before_submit, false)
velocity_fastlane_config(:submit_for_review, false)
velocity_fastlane_config(:automatic_release, false)
velocity_fastlane_config(:submission_information, VelocityAppStoreConnectMetadata.submission_information)

View File

@@ -0,0 +1,113 @@
default_platform(:ios)
deliverfile_path = File.expand_path("Deliverfile", __dir__)
load(deliverfile_path) if File.exist?(deliverfile_path)
platform :ios do
private_lane :release_context do
{
project: "velocity.xcodeproj",
scheme: ENV.fetch("VELOCITY_IOS_SCHEME", "velocity"),
app_identifier: "com.desineuron.velocity.ipad",
configuration: ENV.fetch("VELOCITY_IOS_CONFIGURATION", "Release"),
output_directory: ENV.fetch("VELOCITY_IOS_OUTPUT_DIR", "build/fastlane"),
derived_data_path: ENV.fetch("VELOCITY_IOS_DERIVED_DATA", "build/DerivedData"),
device: ENV.fetch("VELOCITY_IOS_TEST_DEVICE", "iPad Pro (12.9-inch) (6th generation)")
}
end
private_lane :testflight_metadata do
metadata = defined?(VelocityAppStoreConnectMetadata) ? VelocityAppStoreConnectMetadata : nil
UI.user_error!("fastlane/Deliverfile must define VelocityAppStoreConnectMetadata.") unless metadata
{
groups: metadata.testflight_groups,
beta_app_review_info: metadata.beta_app_review_info,
localized_build_info: metadata.localized_build_info,
localized_app_info: metadata.localized_app_info
}
end
desc "Fetch or create Apple Distribution certificate through fastlane cert"
lane :certificates do
cert(
development: false,
force: ENV["FASTLANE_FORCE_CERT"] == "1",
output_path: "fastlane/certs"
)
end
desc "Fetch or create App Store provisioning profile through fastlane sigh"
lane :profiles do
ctx = release_context
sigh(
app_identifier: ctx[:app_identifier],
adhoc: false,
skip_install: false,
force: ENV["FASTLANE_FORCE_PROFILE"] == "1",
filename: "Velocity_iPad_AppStore.mobileprovision",
output_path: "fastlane/profiles"
)
end
desc "Run unit and UI tests for the iPad app"
lane :tests do
ctx = release_context
scan(
project: ctx[:project],
scheme: ctx[:scheme],
devices: [ctx[:device]],
clean: true,
derived_data_path: ctx[:derived_data_path],
result_bundle: true,
output_directory: "#{ctx[:output_directory]}/test-results",
output_types: "html,junit",
include_simulator_logs: true,
fail_build: true
)
end
desc "Build the iPad app and upload it to TestFlight"
lane :beta do
ctx = release_context
metadata = testflight_metadata
certificates
profiles
tests
build_app(
project: ctx[:project],
scheme: ctx[:scheme],
configuration: ctx[:configuration],
clean: true,
export_method: "app-store",
output_directory: ctx[:output_directory],
output_name: "Velocity-iPad.ipa",
derived_data_path: ctx[:derived_data_path],
include_symbols: true,
include_bitcode: false,
xcargs: [
"DEVELOPMENT_TEAM=#{ENV.fetch("FASTLANE_TEAM_ID", "L29922NHD9")}",
"PRODUCT_BUNDLE_IDENTIFIER=#{ctx[:app_identifier]}"
].join(" ")
)
pilot(
ipa: "#{ctx[:output_directory]}/Velocity-iPad.ipa",
app_identifier: ctx[:app_identifier],
skip_waiting_for_build_processing: ENV.fetch("FASTLANE_SKIP_WAITING", "false") == "true",
distribute_external: ENV.fetch("FASTLANE_DISTRIBUTE_EXTERNAL", "1") == "1",
notify_external_testers: ENV["FASTLANE_NOTIFY_EXTERNAL_TESTERS"] == "1",
groups: metadata[:groups],
beta_app_review_info: metadata[:beta_app_review_info],
localized_app_info: metadata[:localized_app_info],
localized_build_info: metadata[:localized_build_info],
beta_app_feedback_email: metadata[:localized_app_info]["default"][:feedback_email],
beta_app_description: metadata[:localized_app_info]["default"][:description],
demo_account_required: true,
uses_non_exempt_encryption: false,
changelog: metadata[:localized_build_info]["default"][:whats_new]
)
end
end

View File

@@ -1,21 +1,24 @@
import SwiftUI
import LocalAuthentication
import UIKit
enum AppSection: String, CaseIterable, Hashable, Identifiable {
var id: String { rawValue }
case dashboard = "Dashboard"
case clients = "Clients"
case imports = "Imports"
case communications = "Communications"
case calendar = "Calendar"
case oracle = "Oracle"
case sentinel = "Sentinel"
case inventory = "Inventory"
case settings = "Settings"
var displayTitle: String {
rawValue
}
var dockTitle: String {
switch self {
case .sentinel:
return SentinelScope.navigationTitle
case .communications:
return "Comms"
default:
return rawValue
}
@@ -25,11 +28,8 @@ enum AppSection: String, CaseIterable, Hashable, Identifiable {
switch self {
case .dashboard: return "square.grid.2x2"
case .clients: return "person.text.rectangle"
case .imports: return "tray.and.arrow.down"
case .communications: return "phone.connection"
case .calendar: return "calendar.badge.clock"
case .oracle: return "message.and.waveform"
case .sentinel: return "person.crop.rectangle"
case .inventory: return "shippingbox"
case .settings: return "gearshape"
}
@@ -39,11 +39,8 @@ enum AppSection: String, CaseIterable, Hashable, Identifiable {
switch self {
case .dashboard: return VelocityTheme.accent
case .clients: return Color(red: 0.22, green: 0.78, blue: 0.96)
case .imports: return Color(red: 0.94, green: 0.70, blue: 0.25)
case .communications: return Color(red: 0.19, green: 0.84, blue: 0.63)
case .calendar: return Color(red: 0.96, green: 0.67, blue: 0.16)
case .oracle: return Color(red: 0.13, green: 0.83, blue: 0.93) // cyan
case .sentinel: return Color(red: 0.60, green: 0.57, blue: 0.99) // indigo
case .inventory: return VelocityTheme.warning
case .settings: return VelocityTheme.mutedFg
}
@@ -51,102 +48,233 @@ enum AppSection: String, CaseIterable, Hashable, Identifiable {
}
struct ContentView: View {
@State private var selectedSection: AppSection? = .dashboard
@State private var selectedSection: AppSection = .dashboard
@State private var session = SessionStore.shared
@State private var store = AppStore.shared
@State private var isOraclePresented = false
@State private var dockFocusedSection: AppSection?
@State private var isPrivacyLocked = true
@State private var isAuthenticating = false
@State private var privacyMessage = "Unlock Velocity"
@Environment(\.scenePhase) private var scenePhase
@Namespace private var dockSelectionNamespace
var body: some View {
Group {
if session.isConfigured {
NavigationSplitView(columnVisibility: .constant(.all)) {
sidebarContent
} detail: {
ZStack(alignment: .bottom) {
detailContent
floatingNavigationPill
.padding(.horizontal, 24)
.padding(.bottom, 24)
}
.overlay(alignment: .bottomTrailing) {
OracleFloatingOrb(alertCount: oracleAlertCount) {
isOraclePresented = true
}
.padding(.trailing, 28)
.padding(.bottom, selectedSection == .clients ? 154 : 112)
}
.overlay(alignment: .top) {
OfflineSyncGlow(isActive: hasPendingSync)
}
.overlay(alignment: .top) {
VaultShareToast(
message: store.vaultShareMessage,
error: store.vaultShareError
) {
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.86)) {
store.vaultShareMessage = nil
store.vaultShareError = nil
}
}
.padding(.top, 18)
}
.overlay {
if store.isShowroomModeEnabled, store.errorMessage != nil {
ShowroomAmbientFallback()
.transition(.opacity.animation(.interactiveSpring(response: 0.55, dampingFraction: 0.9)))
}
}
.overlay {
if isPrivacyLocked {
PrivacyLockOverlay(
message: privacyMessage,
isAuthenticating: isAuthenticating
) {
authenticateSession()
}
.transition(.opacity.animation(.interactiveSpring(response: 0.35, dampingFraction: 0.88)))
}
}
.sheet(isPresented: $isOraclePresented) {
OracleConciergeSheet()
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
}
.navigationSplitViewStyle(.balanced)
} else {
ConfigurationGateView()
}
}
}
// MARK: Sidebar
private var sidebarContent: some View {
ZStack {
VelocityTheme.sidebarBg.ignoresSafeArea()
VStack(spacing: 0) {
// App title
HStack(spacing: 10) {
ZStack {
RoundedRectangle(cornerRadius: 9)
.fill(VelocityTheme.accent.opacity(0.18))
.frame(width: 34, height: 34)
Image(systemName: "bolt.fill")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(VelocityTheme.accent)
}
VStack(alignment: .leading, spacing: 1) {
Text("Velocity")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text("Project Velocity · v1.1")
.font(.system(size: 10))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
.onAppear {
authenticateSession()
}
.onChange(of: scenePhase) { _, phase in
switch phase {
case .background, .inactive:
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.9)) {
isPrivacyLocked = true
privacyMessage = "Velocity is locked"
}
.padding(.horizontal, 16)
.padding(.top, 20)
.padding(.bottom, 16)
Divider()
.background(VelocityTheme.borderSubtle)
.padding(.bottom, 8)
// Nav items
VStack(spacing: 2) {
ForEach(AppSection.allCases) { section in
Button {
selectedSection = section
} label: {
SidebarRow(section: section, isSelected: selectedSection == section)
}
.buttonStyle(.plain)
.accessibilityLabel(section.displayTitle)
.accessibilityAddTraits(selectedSection == section ? [.isSelected] : [])
}
case .active:
if isPrivacyLocked {
authenticateSession()
}
@unknown default:
break
}
}
}
private var floatingNavigationPill: some View {
VStack(spacing: 6) {
HStack(alignment: .bottom, spacing: 12) {
ForEach(AppSection.allCases) { section in
let isSelected = selectedSection == section
let isFocused = dockFocusedSection == section
Button {
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
withAnimation(.interactiveSpring(response: 0.46, dampingFraction: 0.78)) {
selectedSection = section
dockFocusedSection = section
}
hideDockTooltipAfterTap(for: section)
} label: {
dockItem(for: section, isSelected: isSelected, isFocused: isFocused)
}
.buttonStyle(.plain)
.accessibilityLabel(section.dockTitle)
.accessibilityAddTraits(isSelected ? [.isSelected] : [])
.onHover { hovering in
withAnimation(.interactiveSpring(response: 0.34, dampingFraction: 0.76)) {
dockFocusedSection = hovering ? section : nil
}
}
}
}
.padding(.horizontal, 13)
.padding(.top, 9)
.padding(.bottom, 8)
}
.background(
RoundedRectangle(cornerRadius: 30, style: .continuous)
.fill(.ultraThinMaterial)
.background(
RoundedRectangle(cornerRadius: 30, style: .continuous)
.fill(Color.black.opacity(0.34))
.blur(radius: 0.5)
)
.shadow(color: Color.black.opacity(0.42), radius: 28, y: 18)
)
.overlay(
RoundedRectangle(cornerRadius: 30, style: .continuous)
.stroke(Color.white.opacity(0.16), lineWidth: 1)
)
.frame(maxWidth: .infinity, alignment: .center)
}
@ViewBuilder
private func dockItem(for section: AppSection, isSelected: Bool, isFocused: Bool) -> some View {
VStack(spacing: 7) {
ZStack {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(
LinearGradient(
colors: [
section.accentColor.opacity(isSelected ? 0.34 : 0.18),
Color.white.opacity(isSelected ? 0.12 : 0.05),
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(Color.white.opacity(isSelected ? 0.26 : 0.12), lineWidth: 1)
)
.shadow(
color: section.accentColor.opacity(isSelected || isFocused ? 0.32 : 0.10),
radius: isSelected || isFocused ? 16 : 7,
y: isSelected || isFocused ? 8 : 3
)
.if(isSelected) { view in
view.matchedGeometryEffect(id: "selectedDockTile", in: dockSelectionNamespace)
}
Image(systemName: section.systemImage)
.font(.system(size: isFocused ? 24 : (isSelected ? 22 : 19), weight: .semibold))
.foregroundStyle(isSelected ? VelocityTheme.foreground : section.accentColor)
}
.frame(width: 50, height: 50)
.scaleEffect(isFocused ? 1.34 : (isSelected ? 1.16 : 1.0), anchor: .bottom)
.offset(y: isFocused ? -13 : (isSelected ? -5 : 0))
Circle()
.fill(isSelected ? section.accentColor.opacity(0.92) : Color.clear)
.frame(width: 5, height: 5)
.shadow(color: section.accentColor.opacity(isSelected ? 0.45 : 0), radius: 5)
}
.frame(width: 58, height: 70, alignment: .bottom)
.overlay(alignment: .top) {
if isFocused {
dockTooltip(section.dockTitle)
.offset(y: -46)
.transition(
.asymmetric(
insertion: .scale(scale: 0.88, anchor: .bottom).combined(with: .opacity),
removal: .opacity
)
)
}
}
.contentShape(Rectangle())
.animation(.interactiveSpring(response: 0.44, dampingFraction: 0.76), value: isSelected)
.animation(.interactiveSpring(response: 0.32, dampingFraction: 0.70), value: isFocused)
}
private func dockTooltip(_ title: String) -> some View {
VStack(spacing: 0) {
Text(title)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
.lineLimit(1)
.padding(.horizontal, 11)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(Color(red: 0.13, green: 0.13, blue: 0.13).opacity(0.96))
.overlay(
RoundedRectangle(cornerRadius: 6, style: .continuous)
.stroke(Color.white.opacity(0.18), lineWidth: 1)
)
)
Triangle()
.fill(Color(red: 0.13, green: 0.13, blue: 0.13).opacity(0.96))
.frame(width: 12, height: 7)
}
.fixedSize()
.shadow(color: Color.black.opacity(0.42), radius: 10, y: 6)
}
private func hideDockTooltipAfterTap(for section: AppSection) {
Task {
try? await Task.sleep(nanoseconds: 1_200_000_000)
await MainActor.run {
guard dockFocusedSection == section else { return }
withAnimation(.interactiveSpring(response: 0.32, dampingFraction: 0.78)) {
dockFocusedSection = nil
}
.padding(.horizontal, 8)
Spacer()
// User footer
Divider()
.background(VelocityTheme.borderSubtle)
HStack(spacing: 10) {
ZStack {
RoundedRectangle(cornerRadius: 8)
.fill(VelocityTheme.accent)
.frame(width: 32, height: 32)
Text(operatorInitials)
.font(.system(size: 11, weight: .bold))
.foregroundStyle(.white)
}
VStack(alignment: .leading, spacing: 2) {
Text(operatorName)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(VelocityTheme.foreground)
Text(session.authModeDescription)
.font(.system(size: 10))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
}
.padding(16)
}
}
.navigationTitle("")
.toolbar(.hidden, for: .navigationBar)
}
// MARK: Detail
@@ -156,16 +284,17 @@ struct ContentView: View {
Group {
switch selectedSection {
case .dashboard: DashboardView()
case .dashboard:
DashboardView { section in
withAnimation(.interactiveSpring(response: 0.46, dampingFraction: 0.82)) {
selectedSection = section
}
}
case .clients: ClientsView()
case .imports: ImportsView()
case .communications: CommunicationsView()
case .calendar: CalendarView()
case .oracle: OracleView()
case .sentinel: SentinelView()
case .inventory: InventoryView()
case .settings: SettingsView()
case .none: DashboardView()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -185,37 +314,253 @@ struct ContentView: View {
let initials = parts.prefix(2).compactMap(\.first)
return initials.isEmpty ? "VO" : String(initials)
}
private var hasPendingSync: Bool {
!store.isShowroomModeEnabled && (!store.pendingSyncTaskIDs.isEmpty || !store.pendingSyncCalendarEventIDs.isEmpty)
}
private var oracleAlertCount: Int {
guard let alertSnapshot = store.alertSnapshot else { return 0 }
return alertSnapshot.pendingInsights + alertSnapshot.pendingTranscriptions + alertSnapshot.upcomingCalendarEvents24h
}
private func authenticateSession() {
guard session.isConfigured, !isAuthenticating else { return }
isAuthenticating = true
privacyMessage = "Authenticating..."
let context = LAContext()
context.localizedCancelTitle = "Lock"
var error: NSError?
let policy: LAPolicy = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
? .deviceOwnerAuthenticationWithBiometrics
: .deviceOwnerAuthentication
guard context.canEvaluatePolicy(policy, error: &error) else {
isAuthenticating = false
privacyMessage = error?.localizedDescription ?? "Device authentication is unavailable."
return
}
context.evaluatePolicy(policy, localizedReason: "Unlock Project Velocity") { success, authError in
Task { @MainActor in
isAuthenticating = false
withAnimation(.interactiveSpring(response: 0.38, dampingFraction: 0.86)) {
isPrivacyLocked = !success
}
privacyMessage = success
? "Unlocked"
: (authError?.localizedDescription ?? "Authentication failed.")
}
}
}
}
// MARK: Sidebar Row
private struct SidebarRow: View {
let section: AppSection
let isSelected: Bool
private extension View {
@ViewBuilder
func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
if condition {
transform(self)
} else {
self
}
}
}
private struct Triangle: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.midX, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.minX, y: rect.minY))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
path.closeSubpath()
return path
}
}
private struct OracleFloatingOrb: View {
let alertCount: Int
let action: () -> Void
@State private var isPressed = false
var body: some View {
HStack(spacing: 11) {
Image(systemName: section.systemImage)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(isSelected ? section.accentColor : VelocityTheme.mutedFg)
.frame(width: 20)
Text(section.displayTitle)
.font(.system(size: 14, weight: isSelected ? .semibold : .regular))
.foregroundStyle(isSelected ? VelocityTheme.foreground : VelocityTheme.mutedFg)
Spacer()
Button {
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
action()
} label: {
ZStack {
Circle()
.fill(.ultraThinMaterial)
.frame(width: 64, height: 64)
.overlay(
Circle()
.stroke(Color(red: 0.13, green: 0.83, blue: 0.93).opacity(0.55), lineWidth: 1)
)
.shadow(color: Color(red: 0.13, green: 0.83, blue: 0.93).opacity(0.35), radius: isPressed ? 10 : 18)
Image(systemName: "sparkles")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(Color(red: 0.68, green: 0.95, blue: 1.0))
if alertCount > 0 {
Text(alertCount > 99 ? "99+" : "\(alertCount)")
.font(.system(size: 9, weight: .bold))
.foregroundStyle(.white)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(Capsule().fill(VelocityTheme.danger))
.offset(x: 22, y: -22)
.transition(.scale.combined(with: .opacity))
}
}
}
.padding(.horizontal, 12)
.padding(.vertical, 9)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(isSelected ? section.accentColor.opacity(0.12) : .clear)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(isSelected ? section.accentColor.opacity(0.25) : .clear, lineWidth: 1)
)
.buttonStyle(.plain)
.accessibilityLabel("Open Oracle")
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in
withAnimation(.interactiveSpring(response: 0.24, dampingFraction: 0.8)) {
isPressed = true
}
}
.onEnded { _ in
withAnimation(.interactiveSpring(response: 0.28, dampingFraction: 0.82)) {
isPressed = false
}
}
)
.contentShape(Rectangle())
}
}
private struct OfflineSyncGlow: View {
let isActive: Bool
var body: some View {
Rectangle()
.fill(VelocityTheme.warning.opacity(isActive ? 0.28 : 0))
.frame(height: isActive ? 5 : 0)
.shadow(color: VelocityTheme.warning.opacity(isActive ? 0.65 : 0), radius: 14, y: 5)
.animation(.interactiveSpring(response: 0.42, dampingFraction: 0.9), value: isActive)
.ignoresSafeArea(edges: .top)
}
}
private struct VaultShareToast: View {
let message: String?
let error: String?
let dismiss: () -> Void
var body: some View {
if let text = message ?? error {
HStack(spacing: 10) {
Image(systemName: error == nil ? "link.circle.fill" : "exclamationmark.triangle.fill")
.foregroundStyle(error == nil ? VelocityTheme.success : VelocityTheme.warning)
Text(text)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Button {
dismiss()
} label: {
Image(systemName: "xmark")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(VelocityTheme.mutedFg)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 14)
.padding(.vertical, 9)
.background(
Capsule()
.fill(.ultraThinMaterial)
.overlay(Capsule().stroke(Color.white.opacity(0.16), lineWidth: 1))
)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
}
private struct ShowroomAmbientFallback: View {
var body: some View {
ZStack {
LinearGradient(
colors: [
Color.black.opacity(0.88),
Color(red: 0.045, green: 0.055, blue: 0.075).opacity(0.96),
Color.black.opacity(0.92),
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
VStack(spacing: 14) {
Image(systemName: "building.2.crop.circle")
.font(.system(size: 58, weight: .light))
.foregroundStyle(.white.opacity(0.72))
Text("Velocity")
.font(.system(size: 34, weight: .semibold))
.foregroundStyle(.white)
Text("Preparing the showroom view")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.white.opacity(0.58))
}
}
}
}
private struct PrivacyLockOverlay: View {
let message: String
let isAuthenticating: Bool
let unlock: () -> Void
var body: some View {
ZStack {
Rectangle()
.fill(.ultraThinMaterial)
.ignoresSafeArea()
Color.black.opacity(0.62)
.ignoresSafeArea()
VStack(spacing: 16) {
Image(systemName: "faceid")
.font(.system(size: 50, weight: .light))
.foregroundStyle(VelocityTheme.foreground)
Text("Velocity")
.font(.system(size: 30, weight: .semibold, design: .default))
.foregroundStyle(VelocityTheme.foreground)
Text(message)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(VelocityTheme.mutedFg)
Button {
unlock()
} label: {
HStack(spacing: 8) {
if isAuthenticating {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
} else {
Image(systemName: "lock.open")
}
Text(isAuthenticating ? "Unlocking" : "Unlock")
}
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white)
.padding(.horizontal, 20)
.padding(.vertical, 11)
.background(Capsule().fill(VelocityTheme.accent))
}
.buttonStyle(.plain)
.disabled(isAuthenticating)
}
.padding(28)
.background(
RoundedRectangle(cornerRadius: 26)
.fill(Color.black.opacity(0.32))
.overlay(
RoundedRectangle(cornerRadius: 26)
.stroke(Color.white.opacity(0.16), lineWidth: 1)
)
)
}
}
}

View File

@@ -0,0 +1,49 @@
#usda 1.0
(
defaultPrim = "Building"
metersPerUnit = 1
upAxis = "Y"
)
def Xform "Building"
{
def Cube "Podium"
{
double size = 1
double3 xformOp:translate = (0, 0.08, 0)
double3 xformOp:scale = (4.8, 0.16, 3.4)
uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"]
}
def Cube "TowerA"
{
double size = 1
double3 xformOp:translate = (-1.15, 1.38, -0.35)
double3 xformOp:scale = (1.05, 2.6, 1.0)
uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"]
}
def Cube "TowerB"
{
double size = 1
double3 xformOp:translate = (1.05, 1.75, 0.25)
double3 xformOp:scale = (1.25, 3.35, 1.1)
uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"]
}
def Cube "AmenityDeck"
{
double size = 1
double3 xformOp:translate = (0, 0.42, 1.2)
double3 xformOp:scale = (3.2, 0.18, 0.85)
uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"]
}
def Cube "Courtyard"
{
double size = 1
double3 xformOp:translate = (0, 0.18, -1.05)
double3 xformOp:scale = (1.5, 0.05, 0.9)
uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"]
}
}

Binary file not shown.

View File

@@ -2,8 +2,7 @@ import Foundation
import Security
/// Central app configuration.
/// Build settings remain the fallback, but production installs should prefer
/// runtime configuration stored on-device.
/// Enterprise installs must use runtime configuration stored on-device.
enum AppConfig {
private static let runtimeBaseURLKey = "velocity.runtime.base_url"
private static let runtimeDreamWeaverBaseURLKey = "velocity.runtime.dream_weaver_base_url"
@@ -42,10 +41,6 @@ enum AppConfig {
return "Credentials required"
}
private static func value(for key: String) -> String? {
parsedValue(from: Bundle.main.infoDictionary, key: key)
}
private static func sanitizedValue(_ raw: String?, key: String) -> String? {
guard let raw else {
return nil
@@ -59,7 +54,7 @@ enum AppConfig {
/// Base URL for the Velocity backend / gateway.
static var baseURL: String {
runtimeBaseURL ?? value(for: "BASE_URL") ?? "https://velocity.desineuron.in/api"
runtimeBaseURL ?? SessionConfigurationDefaults.productionBaseURL
}
/// Dedicated Dream Weaver gateway endpoint when configured; otherwise
@@ -76,19 +71,19 @@ enum AppConfig {
}
static var dreamWeaverAPIKey: String? {
runtimeDreamWeaverAPIKey ?? value(for: "DREAM_WEAVER_API_KEY")
runtimeDreamWeaverAPIKey
}
static var apiEmail: String? {
runtimeEmail ?? value(for: "API_EMAIL")
runtimeEmail
}
static var apiPassword: String? {
runtimePassword ?? value(for: "API_PASSWORD")
runtimePassword
}
static var apiBearerToken: String? {
runtimeBearerToken ?? value(for: "API_BEARER_TOKEN")
runtimeBearerToken
}
static var apiAccessToken: String? {
@@ -132,7 +127,7 @@ enum AppConfig {
email: apiEmail,
hasPassword: apiPassword != nil,
hasBearerToken: apiBearerToken != nil,
source: hasStoredRuntimeConfiguration ? .secureDeviceStorage : .buildConfiguration
source: .secureDeviceStorage
)
}
@@ -144,23 +139,15 @@ enum AppConfig {
password: String?,
bearerToken: String?
) throws {
UserDefaults.standard.set(baseURL, forKey: runtimeBaseURLKey)
if let dreamWeaverBaseURL {
UserDefaults.standard.set(dreamWeaverBaseURL, forKey: runtimeDreamWeaverBaseURLKey)
} else {
UserDefaults.standard.removeObject(forKey: runtimeDreamWeaverBaseURLKey)
}
if let email {
UserDefaults.standard.set(email, forKey: runtimeEmailKey)
} else {
UserDefaults.standard.removeObject(forKey: runtimeEmailKey)
}
try storeSecret(baseURL, account: runtimeBaseURLKey)
try storeSecret(dreamWeaverBaseURL, account: runtimeDreamWeaverBaseURLKey)
try storeSecret(email, account: runtimeEmailKey)
try storeSecret(dreamWeaverAPIKey, account: runtimeDreamWeaverAPIKeyKey)
try storeSecret(password, account: runtimePasswordKey)
try storeSecret(bearerToken, account: runtimeBearerTokenKey)
UserDefaults.standard.removeObject(forKey: runtimeBaseURLKey)
UserDefaults.standard.removeObject(forKey: runtimeDreamWeaverBaseURLKey)
UserDefaults.standard.removeObject(forKey: runtimeEmailKey)
try clearStoredAccessToken()
}
@@ -168,6 +155,9 @@ enum AppConfig {
UserDefaults.standard.removeObject(forKey: runtimeBaseURLKey)
UserDefaults.standard.removeObject(forKey: runtimeDreamWeaverBaseURLKey)
UserDefaults.standard.removeObject(forKey: runtimeEmailKey)
try deleteSecret(account: runtimeBaseURLKey)
try deleteSecret(account: runtimeDreamWeaverBaseURLKey)
try deleteSecret(account: runtimeEmailKey)
try deleteSecret(account: runtimeDreamWeaverAPIKeyKey)
try deleteSecret(account: runtimePasswordKey)
try deleteSecret(account: runtimeBearerTokenKey)
@@ -186,16 +176,29 @@ enum AppConfig {
}
private static var runtimeBaseURL: String? {
sanitizedValue(UserDefaults.standard.string(forKey: runtimeBaseURLKey), key: runtimeBaseURLKey)
canonicalizedBackendBaseURL(
sanitizedValue(secret(account: runtimeBaseURLKey), key: runtimeBaseURLKey)
)
}
private static func canonicalizedBackendBaseURL(_ value: String?) -> String? {
guard let value else {
return nil
}
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.caseInsensitiveCompare(SessionConfigurationDefaults.legacyVelocityWebBaseURL) == .orderedSame {
return SessionConfigurationDefaults.productionBaseURL
}
return trimmed
}
private static var configuredDreamWeaverBaseURL: String? {
runtimeDreamWeaverBaseURL ?? value(for: "DREAM_WEAVER_BASE_URL")
runtimeDreamWeaverBaseURL
}
private static var runtimeDreamWeaverBaseURL: String? {
sanitizedValue(
UserDefaults.standard.string(forKey: runtimeDreamWeaverBaseURLKey),
secret(account: runtimeDreamWeaverBaseURLKey),
key: runtimeDreamWeaverBaseURLKey
)
}
@@ -205,7 +208,7 @@ enum AppConfig {
}
private static var runtimeEmail: String? {
sanitizedValue(UserDefaults.standard.string(forKey: runtimeEmailKey), key: runtimeEmailKey)
sanitizedValue(secret(account: runtimeEmailKey), key: runtimeEmailKey)
}
private static var runtimePassword: String? {

View File

@@ -12,6 +12,12 @@ enum SessionConfigurationSource: String {
case secureDeviceStorage = "Secure device storage"
}
enum SessionConfigurationDefaults {
static let productionBaseURL = "https://api.desineuron.in/api"
static let legacyVelocityWebBaseURL = "https://velocity.desineuron.in/api"
static let dreamWeaverBaseURL = "https://dreamweaver.desineuron.in"
}
struct AppSessionConfiguration: Equatable {
let baseURL: String
let dreamWeaverBaseURL: String
@@ -89,7 +95,13 @@ struct SessionConfigurationDraft: Equatable {
}
var normalizedBaseURL: String? {
Self.normalizedHTTPSOrigin(from: trimmedBaseURL)
guard let normalized = Self.normalizedHTTPSOrigin(from: trimmedBaseURL) else {
return nil
}
if normalized.caseInsensitiveCompare(SessionConfigurationDefaults.legacyVelocityWebBaseURL) == .orderedSame {
return SessionConfigurationDefaults.productionBaseURL
}
return normalized
}
var trimmedDreamWeaverBaseURL: String? {
@@ -118,7 +130,7 @@ struct SessionConfigurationDraft: Equatable {
}
guard normalizedBaseURL != nil else {
errors.append("Backend endpoint must be an HTTPS API base like https://velocity.desineuron.in/api.")
errors.append("Backend endpoint must be an HTTPS API base like \(SessionConfigurationDefaults.productionBaseURL).")
return errors
}

View File

@@ -75,8 +75,8 @@ final class SessionStore {
func reloadFromPersistedConfiguration() {
currentConfiguration = AppConfig.currentSessionConfiguration()
draftBaseURL = currentConfiguration.baseURL
draftDreamWeaverBaseURL = persistedDreamWeaverDraftValue
draftBaseURL = trimmedNonEmpty(currentConfiguration.baseURL) ?? SessionConfigurationDefaults.productionBaseURL
draftDreamWeaverBaseURL = trimmedNonEmpty(persistedDreamWeaverDraftValue) ?? SessionConfigurationDefaults.dreamWeaverBaseURL
draftDreamWeaverAPIKey = ""
draftAuthMode = currentConfiguration.authMode
draftEmail = currentConfiguration.email ?? ""
@@ -88,6 +88,11 @@ final class SessionStore {
baselineEmail = currentConfiguration.email
}
func markDraftEdited() {
errorMessage = nil
statusMessage = nil
}
func discardDraftChanges() {
errorMessage = nil
statusMessage = nil
@@ -185,6 +190,11 @@ final class SessionStore {
currentConfiguration.usesDedicatedDreamWeaverBaseURL ? currentConfiguration.dreamWeaverBaseURL : ""
}
private func trimmedNonEmpty(_ value: String) -> String? {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
private func verificationStatusMessage(
successPrefix: String,
backendRefreshError: String?,

View File

@@ -18,29 +18,71 @@ final class ComfyClient {
// MARK: - Health Check
/// Call on app launch to confirm the Dream Weaver gateway is reachable
/// and the Dream Weaver routes are actually mounted behind it.
func checkHealth() async -> Bool {
func checkReadiness() async -> DreamWeaverReadiness {
do {
var request = authorizedRequest(url: try resolvedURL(candidate: nil, fallbackPath: "/health"))
request.timeoutInterval = 30.0
let (data, response) = try await urlSession.data(for: request)
guard let http = response as? HTTPURLResponse, 200..<300 ~= http.statusCode else {
return false
return DreamWeaverReadiness(
isReady: false,
label: "Gateway offline",
detail: "Dream Weaver gateway did not return a healthy /health response."
)
}
let json = try JSONDecoder().decode(HealthResponse.self, from: data)
guard ["ok", "healthy"].contains(json.status.lowercased()) else {
return false
let health = try JSONDecoder().decode(HealthResponse.self, from: data)
guard ["ok", "healthy"].contains(health.status.lowercased()) else {
return DreamWeaverReadiness(
isReady: false,
label: "Gateway unhealthy",
detail: "Dream Weaver gateway reported status: \(health.status)."
)
}
guard health.comfyui != false else {
return DreamWeaverReadiness(
isReady: false,
label: "ComfyUI offline",
detail: "The gateway is online, but ComfyUI/GPU is not reachable."
)
}
guard health.checkpointReady != false else {
return DreamWeaverReadiness(
isReady: false,
label: "Checkpoint missing",
detail: "ComfyUI is online, but no compatible Dream Weaver checkpoint is available."
)
}
guard try await probeDreamWeaverRoute() else {
return DreamWeaverReadiness(
isReady: false,
label: "Route not mounted",
detail: "The /dream-weaver route family is not mounted behind the configured gateway."
)
}
return try await probeDreamWeaverRoute()
return DreamWeaverReadiness(
isReady: true,
label: "Ready",
detail: "Gateway, Dream Weaver route, ComfyUI, and checkpoint checks passed."
)
} catch {
return false
return DreamWeaverReadiness(
isReady: false,
label: "Gateway offline",
detail: error.localizedDescription
)
}
}
/// Call on app launch to confirm the Dream Weaver gateway is reachable
/// and the Dream Weaver routes are actually mounted behind it.
func checkHealth() async -> Bool {
let readiness = await checkReadiness()
return readiness.isReady
}
// MARK: - Main Generation Pipeline
/// Full pipeline: upload queue poll download.
@@ -49,6 +91,10 @@ final class ComfyClient {
/// - roomType: The type of room being designed (e.g. "bedroom", "living_room").
/// - keywords: Comma-separated user keywords appended to the style prompt (can be empty).
func generateImage(source: UIImage, roomType: String, keywords: String) async throws -> UIImage {
try await generateImageResult(source: source, roomType: roomType, keywords: keywords).image
}
func generateImageResult(source: UIImage, roomType: String, keywords: String) async throws -> DreamWeaverGenerationResult {
let normalised = source.fixedOrientation()
let resized = normalised.resizedSquare(to: 1024)
guard let imageData = resized.jpegData(compressionQuality: 0.85) else {
@@ -62,7 +108,8 @@ final class ComfyClient {
let resultURL = try await pollUntilReady(job: job)
// 3. Download result PNG
return try await downloadResult(from: resultURL)
let image = try await downloadResult(from: resultURL)
return DreamWeaverGenerationResult(image: image, resultURL: resultURL)
}
// MARK: - Step 1: POST /dream-weaver
@@ -266,9 +313,48 @@ struct JobStatus: Codable {
}
}
struct HealthResponse: Codable {
struct DreamWeaverReadiness: Equatable {
let isReady: Bool
let label: String
let detail: String
}
struct DreamWeaverGenerationResult {
let image: UIImage
let resultURL: URL
}
struct HealthResponse: Decodable {
let status: String
let comfyui: Bool?
let checkpointReady: Bool?
enum CodingKeys: String, CodingKey {
case status
case comfyui
case checkpointReady = "checkpoint_ready"
case preferredCheckpointAvailable = "preferred_checkpoint_available"
case checkpointAvailable = "checkpoint_available"
case hasCheckpoint = "has_checkpoint"
case gpuReady = "gpu_ready"
}
init(status: String, comfyui: Bool?, checkpointReady: Bool?) {
self.status = status
self.comfyui = comfyui
self.checkpointReady = checkpointReady
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
status = try container.decodeIfPresent(String.self, forKey: .status) ?? "unknown"
comfyui = try container.decodeIfPresent(Bool.self, forKey: .comfyui)
?? container.decodeIfPresent(Bool.self, forKey: .gpuReady)
checkpointReady = try container.decodeIfPresent(Bool.self, forKey: .checkpointReady)
?? container.decodeIfPresent(Bool.self, forKey: .preferredCheckpointAvailable)
?? container.decodeIfPresent(Bool.self, forKey: .checkpointAvailable)
?? container.decodeIfPresent(Bool.self, forKey: .hasCheckpoint)
}
}
struct DreamWeaverErrorResponse: Codable {

View File

@@ -0,0 +1,130 @@
import SwiftUI
import UIKit
struct VelocityVaultShareAsset {
let leadId: String?
let assetName: String
let assetType: String
let storagePath: String?
var isShareable: Bool {
leadId?.trimmedNonEmpty != nil && storagePath?.trimmedNonEmpty != nil
}
}
extension URL {
var velocityStoragePath: String {
let cleaned = path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
if cleaned.hasPrefix("assets/") {
return String(cleaned.dropFirst("assets/".count))
}
return cleaned
}
}
extension View {
func vaultSwipeToShare(asset: VelocityVaultShareAsset?) -> some View {
modifier(VaultSwipeToShareModifier(asset: asset))
}
}
private struct VaultSwipeToShareModifier: ViewModifier {
@State private var appStore = AppStore.shared
let asset: VelocityVaultShareAsset?
func body(content: Content) -> some View {
content
.overlay {
ThreeFingerSwipeUpRecognizer {
Task { await shareAsset() }
}
.allowsHitTesting(asset != nil)
}
}
@MainActor
private func shareAsset() async {
guard let asset else { return }
guard let leadId = asset.leadId?.trimmedNonEmpty,
let storagePath = asset.storagePath?.trimmedNonEmpty else {
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.86)) {
appStore.vaultShareError = "Vault share requires a backend lead and stored asset path."
appStore.vaultShareMessage = nil
}
return
}
guard let threadId = appStore.activeCommunicationsThreadID?.trimmedNonEmpty else {
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.86)) {
appStore.vaultShareError = "Open a Communications thread before using Vault Swipe-to-Share."
appStore.vaultShareMessage = nil
}
return
}
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
do {
let link = try await VelocityAPIClient.shared.generateVaultLink(
leadId: leadId,
assetName: asset.assetName,
assetType: asset.assetType,
storagePath: storagePath
)
_ = try await VelocityAPIClient.shared.sendCommsMessage(
threadId: threadId,
body: "Secure Velocity Vault link: \(link.vaultUrl)"
)
withAnimation(.interactiveSpring(response: 0.42, dampingFraction: 0.82)) {
appStore.vaultShareMessage = "Vault link shared to the active thread."
appStore.vaultShareError = nil
}
} catch {
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.86)) {
appStore.vaultShareError = error.localizedDescription
appStore.vaultShareMessage = nil
}
}
}
}
private struct ThreeFingerSwipeUpRecognizer: UIViewRepresentable {
let onSwipe: () -> Void
func makeUIView(context: Context) -> UIView {
let view = UIView(frame: .zero)
view.backgroundColor = .clear
let recognizer = UISwipeGestureRecognizer(
target: context.coordinator,
action: #selector(Coordinator.didSwipe(_:))
)
recognizer.direction = .up
recognizer.numberOfTouchesRequired = 3
view.addGestureRecognizer(recognizer)
return view
}
func updateUIView(_ uiView: UIView, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(onSwipe: onSwipe)
}
final class Coordinator: NSObject {
let onSwipe: () -> Void
init(onSwipe: @escaping () -> Void) {
self.onSwipe = onSwipe
}
@objc func didSwipe(_ recognizer: UISwipeGestureRecognizer) {
guard recognizer.state == .ended else { return }
onSwipe()
}
}
}
private extension String {
var trimmedNonEmpty: String? {
let value = trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,12 @@
import Foundation
enum AppStoreRefreshPolicy {
/// Native iPad surfaces refresh on initial view load, pull-to-refresh, and
/// explicit user mutations. View-local repeating timers are intentionally
/// avoided so AppStore can coalesce in-flight refreshes and hydrate mobile
/// edge state through one bulk request.
static let timerDrivenRefreshesEnabled = false
/// Match the WebOS bootstrap so Inventory, Dashboard, and shared summaries
/// are based on the same production property slice by default.
static let inventoryPropertyLimit = 100
@@ -9,8 +15,9 @@ enum AppStoreRefreshPolicy {
/// the operator's active task load on iPad surfaces.
static let canonicalTaskLimit = 50
/// iPad surfaces only render a small operator-focused timeline, so keep the
/// lead-event hydration set intentionally narrower than WebOS.
/// Lead timelines are hydrated through the mobile-edge bulk endpoint. Keep
/// the selected lead set bounded so every shared refresh remains one
/// predictable backend call rather than N per-lead calls.
static let leadTimelineHydrationLimit = 6
/// Fetch enough recent communication context for the visible iPad rails

View File

@@ -0,0 +1,220 @@
import CoreData
import Foundation
struct OfflineReplayRecord: Identifiable {
let id: String
let kind: String
let operation: String
let targetID: String?
let payload: Data
let queuedAt: Date
let attemptCount: Int
let lastAttemptAt: Date?
let lastError: String?
}
actor OfflineReplayStore {
static let shared = OfflineReplayStore()
private enum Schema {
static let entityName = "OfflineReplayItem"
}
private let container: NSPersistentContainer
private let context: NSManagedObjectContext
init() {
let model = Self.makeModel()
container = NSPersistentContainer(name: "VelocityOfflineReplay", managedObjectModel: model)
let applicationSupport = FileManager.default.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
).first ?? FileManager.default.temporaryDirectory
let directory = applicationSupport.appendingPathComponent("Velocity", isDirectory: true)
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let description = NSPersistentStoreDescription(
url: directory.appendingPathComponent("OfflineReplay.sqlite")
)
description.shouldMigrateStoreAutomatically = true
description.shouldInferMappingModelAutomatically = true
#if os(iOS)
description.setOption(
FileProtectionType.complete.rawValue as NSString,
forKey: NSPersistentStoreFileProtectionKey
)
#endif
container.persistentStoreDescriptions = [description]
var loadError: Error?
container.loadPersistentStores { _, error in
loadError = error
}
if let loadError {
assertionFailure("Velocity offline replay store failed to load: \(loadError.localizedDescription)")
}
context = container.newBackgroundContext()
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
}
func enqueue(kind: String, operation: String, targetID: String?, payload: Data) {
let context = context
context.performAndWait {
if let targetID {
let existing = Self.fetchManagedObjects(kind: kind, targetID: targetID, in: context)
existing.forEach(context.delete)
}
guard let entity = NSEntityDescription.entity(forEntityName: Schema.entityName, in: context) else {
return
}
let item = NSManagedObject(entity: entity, insertInto: context)
item.setValue(UUID().uuidString, forKey: "id")
item.setValue(kind, forKey: "kind")
item.setValue(operation, forKey: "operation")
item.setValue(targetID, forKey: "targetID")
item.setValue(payload, forKey: "payload")
item.setValue(Date(), forKey: "queuedAt")
item.setValue(0, forKey: "attemptCount")
item.setValue(nil, forKey: "lastAttemptAt")
item.setValue(nil, forKey: "lastError")
Self.saveIfNeeded(context)
}
}
func pendingRecords(limit: Int = 100) -> [OfflineReplayRecord] {
let context = context
var records: [OfflineReplayRecord] = []
context.performAndWait {
let request = NSFetchRequest<NSManagedObject>(entityName: Schema.entityName)
request.sortDescriptors = [
NSSortDescriptor(key: "queuedAt", ascending: true)
]
request.fetchLimit = limit
let items = (try? context.fetch(request)) ?? []
records = items.compactMap(Self.record(from:))
}
return records
}
func markCompleted(id: String) {
let context = context
context.performAndWait {
Self.fetchManagedObjects(id: id, in: context).forEach(context.delete)
Self.saveIfNeeded(context)
}
}
func markFailed(id: String, error: Error) {
let context = context
context.performAndWait {
for item in Self.fetchManagedObjects(id: id, in: context) {
let currentAttempts = item.value(forKey: "attemptCount") as? Int ?? 0
item.setValue(currentAttempts + 1, forKey: "attemptCount")
item.setValue(Date(), forKey: "lastAttemptAt")
item.setValue(error.localizedDescription, forKey: "lastError")
}
Self.saveIfNeeded(context)
}
}
func remove(kind: String, targetID: String) {
let context = context
context.performAndWait {
Self.fetchManagedObjects(kind: kind, targetID: targetID, in: context).forEach(context.delete)
Self.saveIfNeeded(context)
}
}
func reset() {
let context = context
context.performAndWait {
let request = NSFetchRequest<NSFetchRequestResult>(entityName: Schema.entityName)
let delete = NSBatchDeleteRequest(fetchRequest: request)
_ = try? context.execute(delete)
Self.saveIfNeeded(context)
}
}
private static func fetchManagedObjects(id: String, in context: NSManagedObjectContext) -> [NSManagedObject] {
let request = NSFetchRequest<NSManagedObject>(entityName: Schema.entityName)
request.predicate = NSPredicate(format: "id == %@", id)
request.fetchLimit = 1
return (try? context.fetch(request)) ?? []
}
private static func fetchManagedObjects(
kind: String,
targetID: String,
in context: NSManagedObjectContext
) -> [NSManagedObject] {
let request = NSFetchRequest<NSManagedObject>(entityName: Schema.entityName)
request.predicate = NSPredicate(format: "kind == %@ AND targetID == %@", kind, targetID)
return (try? context.fetch(request)) ?? []
}
private static func saveIfNeeded(_ context: NSManagedObjectContext) {
guard context.hasChanges else { return }
try? context.save()
}
private static func record(from object: NSManagedObject) -> OfflineReplayRecord? {
guard
let id = object.value(forKey: "id") as? String,
let kind = object.value(forKey: "kind") as? String,
let operation = object.value(forKey: "operation") as? String,
let payload = object.value(forKey: "payload") as? Data,
let queuedAt = object.value(forKey: "queuedAt") as? Date
else {
return nil
}
return OfflineReplayRecord(
id: id,
kind: kind,
operation: operation,
targetID: object.value(forKey: "targetID") as? String,
payload: payload,
queuedAt: queuedAt,
attemptCount: object.value(forKey: "attemptCount") as? Int ?? 0,
lastAttemptAt: object.value(forKey: "lastAttemptAt") as? Date,
lastError: object.value(forKey: "lastError") as? String
)
}
private static func makeModel() -> NSManagedObjectModel {
let model = NSManagedObjectModel()
let entity = NSEntityDescription()
entity.name = Schema.entityName
entity.managedObjectClassName = NSStringFromClass(NSManagedObject.self)
entity.properties = [
attribute("id", type: .stringAttributeType, optional: false),
attribute("kind", type: .stringAttributeType, optional: false),
attribute("operation", type: .stringAttributeType, optional: false),
attribute("targetID", type: .stringAttributeType, optional: true),
attribute("payload", type: .binaryDataAttributeType, optional: false),
attribute("queuedAt", type: .dateAttributeType, optional: false),
attribute("attemptCount", type: .integer64AttributeType, optional: false, defaultValue: 0),
attribute("lastAttemptAt", type: .dateAttributeType, optional: true),
attribute("lastError", type: .stringAttributeType, optional: true),
]
model.entities = [entity]
return model
}
private static func attribute(
_ name: String,
type: NSAttributeType,
optional: Bool,
defaultValue: Any? = nil
) -> NSAttributeDescription {
let attribute = NSAttributeDescription()
attribute.name = name
attribute.attributeType = type
attribute.isOptional = optional
attribute.defaultValue = defaultValue
return attribute
}
}

View File

@@ -1,4 +1,3 @@
import Combine
import SwiftUI
private struct CalendarAgendaItem: Identifiable {
@@ -9,6 +8,7 @@ private struct CalendarAgendaItem: Identifiable {
let location: String
let type: String
let color: Color
let pendingSync: Bool
let sortDate: Date?
let event: VelocityCalendarEventDTO?
let task: VelocityTaskDTO?
@@ -58,15 +58,19 @@ struct CalendarView: View {
@State private var actionError: String?
@State private var actionMessage: String?
@State private var actionMessageDismissTask: Task<Void, Never>?
@State private var activeDashboardFocus: VelocityDashboardCalendarFocus?
@State private var activeTaskMutationID: String?
@State private var activeEventMutationID: String?
@State private var undoCancelledTask: VelocityTaskDTO?
@State private var undoCancelledEvent: VelocityCalendarEventDTO?
@State private var isCreateEventPresented = false
@State private var editingEvent: VelocityCalendarEventDTO?
@State private var eventDraft = CalendarEventDraft()
@State private var isCreatingEvent = false
@State private var isSavingEvent = false
@State private var createEventError: String?
private let refreshTimer = Timer.publish(every: 15, on: .main, in: .common).autoconnect()
@State private var schedulingClientPersonID: String?
@State private var targetedDropDay: String?
private let visibleWeekdays = Calendar.current.weekdaySymbols
var body: some View {
@@ -82,10 +86,14 @@ struct CalendarView: View {
if let actionMessage {
successBanner(actionMessage)
}
if let activeDashboardFocus {
dashboardFocusBanner(activeDashboardFocus)
}
if store.isLoading && store.lastRefreshAt == nil {
loadingPanel
} else {
metricsRow
clientSchedulingStrip
HStack(alignment: .top, spacing: 18) {
scheduleRail
agendaPanel
@@ -96,11 +104,14 @@ struct CalendarView: View {
}
.background(VelocityTheme.background)
.scrollContentBackground(.hidden)
.task { await store.refresh() }
.refreshable { await store.refresh() }
.onReceive(refreshTimer) { _ in
Task { await store.refresh(silent: true) }
.task {
await store.refresh()
consumeRequestedCalendarFocus()
}
.onChange(of: store.requestedCalendarFocus) { _, _ in
consumeRequestedCalendarFocus()
}
.refreshable { await store.refresh() }
.onDisappear {
actionMessageDismissTask?.cancel()
actionMessageDismissTask = nil
@@ -108,6 +119,9 @@ struct CalendarView: View {
.sheet(isPresented: $isCreateEventPresented) {
createEventSheet
}
.sheet(item: $editingEvent) { event in
editEventSheet(event)
}
}
private var header: some View {
@@ -141,6 +155,9 @@ struct CalendarView: View {
Button {
selectedDay = Self.currentWeekdayName()
actionError = nil
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.86)) {
activeDashboardFocus = nil
}
} label: {
Text("This week")
.font(.system(size: 11, weight: .semibold))
@@ -207,14 +224,25 @@ struct CalendarView: View {
.padding(14)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(selectedDay == day ? VelocityTheme.accent.opacity(0.12) : VelocityTheme.surface)
.fill(targetedDropDay == day ? VelocityTheme.accent.opacity(0.22) : (selectedDay == day ? VelocityTheme.accent.opacity(0.12) : VelocityTheme.surface))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(selectedDay == day ? VelocityTheme.borderAccent : VelocityTheme.borderSubtle, lineWidth: 1)
.stroke(targetedDropDay == day || selectedDay == day ? VelocityTheme.borderAccent : VelocityTheme.borderSubtle, lineWidth: 1)
)
)
}
.buttonStyle(.plain)
.dropDestination(for: String.self) { personIDs, _ in
guard let personID = personIDs.first?.trimmedNonEmpty else {
return false
}
Task { await scheduleSiteVisit(forPersonID: personID, on: day) }
return true
} isTargeted: { isTargeted in
withAnimation(.interactiveSpring(response: 0.34, dampingFraction: 0.82)) {
targetedDropDay = isTargeted ? day : nil
}
}
}
}
.padding(18)
@@ -222,6 +250,46 @@ struct CalendarView: View {
.glassCard(cornerRadius: 20)
}
private var clientSchedulingStrip: some View {
Group {
if !store.contacts.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(store.contacts.prefix(12)) { contact in
HStack(spacing: 8) {
ZStack {
Circle()
.fill(VelocityTheme.accent.opacity(0.16))
.frame(width: 32, height: 32)
Text(initials(for: contact.fullName))
.font(.system(size: 10, weight: .bold))
.foregroundStyle(VelocityTheme.accent)
}
Text(contact.fullName)
.font(.system(size: 12, weight: .semibold))
.lineLimit(1)
}
.foregroundStyle(VelocityTheme.foreground)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(
Capsule()
.fill(VelocityTheme.surface)
.overlay(Capsule().stroke(VelocityTheme.borderSubtle, lineWidth: 1))
)
.opacity(schedulingClientPersonID == contact.personId ? 0.55 : 1)
.scaleEffect(schedulingClientPersonID == contact.personId ? 0.97 : 1)
.draggable(contact.personId)
.animation(.interactiveSpring(response: 0.32, dampingFraction: 0.86), value: schedulingClientPersonID)
}
}
.padding(.vertical, 2)
}
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
}
private var agendaPanel: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
@@ -263,6 +331,12 @@ struct CalendarView: View {
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Spacer()
if item.pendingSync {
Circle()
.fill(VelocityTheme.warning)
.frame(width: 8, height: 8)
.shadow(color: VelocityTheme.warning.opacity(0.75), radius: 6)
}
Text(item.type)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(item.color)
@@ -499,7 +573,157 @@ struct CalendarView: View {
.presentationDragIndicator(.visible)
}
private func editEventSheet(_ event: VelocityCalendarEventDTO) -> some View {
NavigationStack {
ZStack {
VelocityTheme.background.ignoresSafeArea()
VStack(alignment: .leading, spacing: 18) {
VStack(alignment: .leading, spacing: 4) {
Text("Edit Event")
.font(.system(size: 24, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text("Update the backend-owned calendar slot details.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
if let createEventError {
errorBanner(createEventError)
}
ScrollView {
VStack(alignment: .leading, spacing: 14) {
formLabel("Title")
eventTextField("Site visit with client", text: $eventDraft.title)
HStack(spacing: 12) {
Picker("Status", selection: $eventDraft.status) {
Text("Normal Task").tag("tentative")
Text("Confirmed Task").tag("confirmed")
Text("Done").tag("done")
}
.pickerStyle(.menu)
.tint(VelocityTheme.accent)
Picker("Reminder", selection: $eventDraft.reminderMinutes) {
Text("None").tag(0)
Text("5 min").tag(5)
Text("15 min").tag(15)
Text("30 min").tag(30)
Text("1 hour").tag(60)
Text("1 day").tag(1_440)
}
.pickerStyle(.menu)
.tint(VelocityTheme.accent)
}
.padding(12)
.background(fieldBackground)
DatePicker(
"Starts",
selection: eventStartBinding,
displayedComponents: eventDatePickerComponents
)
.tint(VelocityTheme.accent)
.foregroundStyle(VelocityTheme.foreground)
.padding(12)
.background(fieldBackground)
if !eventDraft.allDay {
DatePicker(
"Ends",
selection: $eventDraft.endDate,
in: eventDraft.startDate.addingTimeInterval(60)...,
displayedComponents: [.date, .hourAndMinute]
)
.tint(VelocityTheme.accent)
.foregroundStyle(VelocityTheme.foreground)
.padding(12)
.background(fieldBackground)
}
formLabel("Location")
eventTextField("Project site, sales lounge, video call", text: $eventDraft.location)
formLabel("Description")
TextEditor(text: $eventDraft.description)
.font(.system(size: 14))
.foregroundStyle(VelocityTheme.foreground)
.scrollContentBackground(.hidden)
.frame(minHeight: 110)
.padding(10)
.background(fieldBackground)
}
.padding(18)
.glassCard(cornerRadius: 18)
}
.frame(height: 436)
HStack(spacing: 12) {
Button {
editingEvent = nil
} label: {
Text("Cancel")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
.frame(maxWidth: .infinity)
.padding(.vertical, 13)
.background(fieldBackground)
}
.buttonStyle(.plain)
Button {
saveEventEdits(event)
} label: {
HStack(spacing: 8) {
if isSavingEvent {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
}
Text(isSavingEvent ? "Saving..." : "Save Event")
}
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 13)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(eventDraft.isValid && !isSavingEvent ? VelocityTheme.accent : VelocityTheme.surface3)
)
}
.buttonStyle(.plain)
.disabled(!eventDraft.isValid || isSavingEvent)
}
}
.padding(24)
.frame(maxWidth: 620)
.frame(maxWidth: .infinity)
}
}
.presentationDetents([.height(690)])
.presentationDragIndicator(.visible)
}
private var filteredAgendaItems: [CalendarAgendaItem] {
if let activeDashboardFocus {
switch activeDashboardFocus {
case .today:
let weekday = Self.currentWeekdayName().lowercased()
return agendaItems.filter {
$0.slot.lowercased().contains(weekday) && !isInactiveAgendaItem($0)
}
case .pendingTasks:
return agendaItems.filter { item in
guard let task = item.task else { return false }
return ["pending", "snoozed", "confirmed"].contains(task.status.lowercased())
}
case .urgentTasks:
return agendaItems.filter { item in
guard let task = item.task else { return false }
return ["urgent", "high"].contains(task.priority.lowercased()) && !isInactiveAgendaItem(item)
}
}
}
let weekday = selectedDay.lowercased()
return agendaItems.filter { $0.slot.lowercased().contains(weekday) }
}
@@ -514,6 +738,7 @@ struct CalendarView: View {
location: event.location ?? "No location",
type: eventStatusLabel(event.status),
color: color(for: event.status),
pendingSync: store.pendingSyncCalendarEventIDs.contains(event.calendarEventId) || event.calendarEventId.hasPrefix("local-"),
sortDate: event.startDate,
event: event,
task: nil
@@ -528,6 +753,7 @@ struct CalendarView: View {
location: task.clientPhone ?? "Canonical CRM task",
type: taskStatusLabel(task),
color: taskColor(for: task),
pendingSync: store.pendingSyncTaskIDs.contains(task.reminderId),
sortDate: task.dueDate,
event: nil,
task: task
@@ -673,6 +899,34 @@ struct CalendarView: View {
)
}
private func dashboardFocusBanner(_ focus: VelocityDashboardCalendarFocus) -> some View {
HStack(spacing: 10) {
Label(calendarFocusLabel(focus), systemImage: "line.3.horizontal.decrease.circle")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(VelocityTheme.warning)
Text(calendarFocusDescription(focus))
.font(.system(size: 12, weight: .medium))
.foregroundStyle(VelocityTheme.mutedFg)
Spacer()
Button("Clear") {
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.86)) {
activeDashboardFocus = nil
}
}
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(VelocityTheme.accent)
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(VelocityTheme.warning.opacity(0.10))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(VelocityTheme.warning.opacity(0.22), lineWidth: 1)
)
)
}
private func buildMetrics(
events: [VelocityCalendarEventDTO],
tasks: [VelocityTaskDTO],
@@ -884,6 +1138,14 @@ struct CalendarView: View {
private func eventActionsMenu(_ event: VelocityCalendarEventDTO) -> some View {
Menu {
let status = event.status.lowercased()
if status != "cancelled" {
Button {
presentEditEvent(event)
} label: {
Label("Edit Event", systemImage: "square.and.pencil")
}
}
if status == "done" {
Button(role: .destructive) {
cancelEvent(event, message: "Task removed.", supportsUndo: false)
@@ -946,6 +1208,79 @@ struct CalendarView: View {
isCreateEventPresented = true
}
private func presentEditEvent(_ event: VelocityCalendarEventDTO) {
let startDate = event.startDate ?? CalendarEventDraft.defaultStartDate()
var draft = CalendarEventDraft(startDate: startDate)
draft.title = event.title
draft.description = event.description ?? ""
draft.location = event.location ?? ""
draft.endDate = event.endDate ?? startDate.addingTimeInterval(60 * 60)
draft.allDay = event.allDay
draft.status = event.status.lowercased()
draft.reminderMinutes = event.reminderMinutes.first ?? 0
eventDraft = draft
createEventError = nil
actionError = nil
clearActionMessage()
editingEvent = event
}
@MainActor
private func scheduleSiteVisit(forPersonID personID: String, on weekday: String) async {
guard schedulingClientPersonID == nil else {
return
}
guard let contact = store.contacts.first(where: { $0.personId == personID }) else {
actionError = "Unable to schedule: this client is not present in the canonical CRM payload."
return
}
guard let leadId = contact.leadId?.trimmedNonEmpty else {
actionError = "Unable to schedule \(contact.fullName): no canonical lead is attached."
return
}
let startDate = defaultEventStartDate(for: weekday)
let endDate = startDate.addingTimeInterval(60 * 60)
var metadata = [
"created_from": "ipad_calendar_drag_drop",
"surface": "velocity_ipad",
"person_id": contact.personId,
"client_name": contact.fullName,
]
if let phone = contact.primaryPhone?.trimmedNonEmpty {
metadata["client_phone"] = phone
}
schedulingClientPersonID = personID
actionError = nil
clearActionMessage()
do {
_ = try await store.createCalendarEvent(
leadId: leadId,
title: "Site visit with \(contact.fullName)",
description: contact.primaryInterest.flatMap { "Property focus: \($0)".trimmedNonEmpty },
startAt: iso8601Timestamp(startDate),
endAt: iso8601Timestamp(endDate),
allDay: false,
status: "confirmed",
reminderMinutes: [60, 15],
location: contact.primaryInterest?.trimmedNonEmpty ?? "Project site",
metadata: metadata
)
withAnimation(.interactiveSpring(response: 0.42, dampingFraction: 0.86)) {
selectedDay = weekday
schedulingClientPersonID = nil
targetedDropDay = nil
}
showActionMessage("Site visit scheduled for \(contact.fullName).")
} catch {
schedulingClientPersonID = nil
targetedDropDay = nil
actionError = calendarActionErrorMessage(error)
}
}
private func createEvent() {
guard eventDraft.isValid else {
createEventError = "Add an event title and make sure the end time is after the start time."
@@ -1004,6 +1339,50 @@ struct CalendarView: View {
}
}
private func saveEventEdits(_ event: VelocityCalendarEventDTO) {
guard eventDraft.isValid else {
createEventError = "Add an event title and make sure the end time is after the start time."
return
}
let calendar = Calendar.current
let startDate = eventDraft.allDay ? calendar.startOfDay(for: eventDraft.startDate) : eventDraft.startDate
let endDate = eventDraft.allDay
? (calendar.date(byAdding: .day, value: 1, to: startDate) ?? startDate.addingTimeInterval(24 * 60 * 60))
: eventDraft.endDate
let reminderMinutes = eventDraft.reminderMinutes > 0 ? [eventDraft.reminderMinutes] : []
createEventError = nil
isSavingEvent = true
Task {
do {
_ = try await store.updateCalendarEvent(
event,
title: eventDraft.title.trimmedNonEmpty ?? event.title,
description: eventDraft.description,
status: eventDraft.status,
startAt: iso8601Timestamp(startDate),
endAt: iso8601Timestamp(endDate),
reminderMinutes: reminderMinutes,
location: eventDraft.location
)
await MainActor.run {
selectedDay = weekdayName(for: startDate)
isSavingEvent = false
editingEvent = nil
showActionMessage("Event updated.")
actionError = nil
}
} catch {
await MainActor.run {
isSavingEvent = false
createEventError = calendarActionErrorMessage(error)
}
}
}
}
private func mutateEvent(
_ event: VelocityCalendarEventDTO,
status: String,
@@ -1170,6 +1549,41 @@ struct CalendarView: View {
}
}
private func consumeRequestedCalendarFocus() {
guard let focus = store.requestedCalendarFocus else {
return
}
store.requestedCalendarFocus = nil
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.86)) {
activeDashboardFocus = focus
if focus == .today {
selectedDay = Self.currentWeekdayName()
}
}
}
private func calendarFocusLabel(_ focus: VelocityDashboardCalendarFocus) -> String {
switch focus {
case .today:
return "Today"
case .pendingTasks:
return "Pending tasks"
case .urgentTasks:
return "Urgent tasks"
}
}
private func calendarFocusDescription(_ focus: VelocityDashboardCalendarFocus) -> String {
switch focus {
case .today:
return "Showing todays confirmed events and CRM reminders."
case .pendingTasks:
return "Showing actionable CRM reminders across the week."
case .urgentTasks:
return "Showing high-priority and urgent CRM reminders."
}
}
private func clearActionMessage(clearUndo: Bool = true) {
actionMessageDismissTask?.cancel()
actionMessageDismissTask = nil
@@ -1233,6 +1647,15 @@ struct CalendarView: View {
return formatter.string(from: date)
}
private func initials(for name: String) -> String {
let pieces = name
.split(separator: " ")
.prefix(2)
.compactMap { $0.first }
let initials = String(pieces).uppercased()
return initials.isEmpty ? "CL" : initials
}
private func menuIcon(_ systemName: String) -> some View {
Image(systemName: systemName)
.font(.system(size: 17, weight: .semibold))

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,39 @@
import Combine
import SwiftUI
private struct ImportRemediationDraft: Identifiable {
let batchId: String
let proposal: VelocityImportProposalDTO
let workbenchRow: VelocityImportWorkbenchRowDTO?
let fields: [String]
var id: String { proposal.proposalId }
init(batchId: String, proposal: VelocityImportProposalDTO, workbenchRow: VelocityImportWorkbenchRowDTO?) {
self.batchId = batchId
self.proposal = proposal
self.workbenchRow = workbenchRow
let canonicalFields: [String] = proposal.payload?.canonicalPayload.map { Array($0.keys) } ?? []
let missingFields = proposal.payload?.missingRequired ?? []
let unresolvedFields = proposal.payload?.unresolvedFields ?? []
let diffFields = workbenchRow?.fieldDiffs.map(\.field) ?? []
let validationFields = workbenchRow?.validation.map(\.field) ?? []
let combinedFields: [String] = canonicalFields + missingFields + unresolvedFields + diffFields + validationFields
fields = Array(Set<String>(combinedFields)).sorted()
}
}
struct ImportsView: View {
@State private var appStore = AppStore.shared
@State private var batches: [VelocityImportBatchSummaryDTO] = []
@State private var selectedBatch: VelocityImportBatchSummaryDTO?
@State private var detail: VelocityImportBatchDetailDTO?
@State private var workbench: VelocityImportWorkbenchDTO?
@State private var isLoading = false
@State private var isCommitting = false
@State private var activeProposalID: String?
@State private var errorMessage: String?
@State private var successMessage: String?
private let refreshTimer = Timer.publish(every: 30, on: .main, in: .common).autoconnect()
@State private var remediationDraft: ImportRemediationDraft?
var body: some View {
HStack(spacing: 0) {
@@ -24,20 +47,40 @@ struct ImportsView: View {
detailPane
}
.background(VelocityTheme.background)
.task { await loadBatches(selectFirst: true) }
.task {
await appStore.ensureCRMVocabulariesLoaded()
await loadBatches(selectFirst: true)
}
.refreshable { await loadBatches(selectFirst: false) }
.onReceive(refreshTimer) { _ in
Task { await loadBatches(selectFirst: false, silent: true) }
.sheet(item: $remediationDraft) { draft in
ImportRemediationSheet(
draft: draft,
duplicatePolicies: appStore.crmVocabularies.importDuplicatePolicies
) { decision, notes, fieldOverrides, duplicatePolicy in
Task {
await reviewProposal(
batchId: draft.batchId,
proposal: draft.proposal,
decision: decision,
notes: notes,
fieldOverrides: fieldOverrides,
duplicatePolicy: duplicatePolicy
)
}
}
}
}
private var batchRail: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 4) {
Text("Imports")
.font(.system(size: 26, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text("Canonical CRM import review and commit queue.")
HStack {
Text("Imports")
.font(.system(size: 26, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Spacer()
}
Text("Read-only canonical CRM import review and remediation queue.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
@@ -77,6 +120,9 @@ struct ImportsView: View {
VStack(alignment: .leading, spacing: 18) {
if let detail {
detailHeader(detail)
if let workbench {
workbenchPanel(workbench)
}
proposalsPanel(detail)
} else if isLoading {
loadingCard("Loading import detail...")
@@ -195,8 +241,115 @@ struct ImportsView: View {
.glassCard(cornerRadius: 20)
}
private func proposalCard(_ proposal: VelocityImportProposalDTO, batchId: String) -> some View {
private func workbenchPanel(_ workbench: VelocityImportWorkbenchDTO) -> some View {
VStack(alignment: .leading, spacing: 14) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Remediation Workbench")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text("Validation, duplicate detection, and canonical CRM row diffs before commit.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
Button {
Task {
if let batchId = detail?.batchId {
await refreshWorkbench(batchId: batchId)
}
}
} label: {
Image(systemName: "arrow.clockwise")
}
.buttonStyle(.plain)
.foregroundStyle(VelocityTheme.accent)
}
HStack(spacing: 12) {
metricCard("Rows", value: "\(workbench.summary.proposalCount)", color: VelocityTheme.accent)
metricCard("Duplicates", value: "\(workbench.summary.duplicateCount)", color: VelocityTheme.warning)
metricCard("Errors", value: "\(workbench.summary.validationErrorCount)", color: VelocityTheme.danger)
metricCard("Warnings", value: "\(workbench.summary.validationWarningCount)", color: VelocityTheme.warning)
}
LazyVStack(spacing: 10) {
ForEach(workbench.rows.prefix(20)) { row in
workbenchRowCard(row)
}
}
}
.padding(18)
.glassCard(cornerRadius: 20)
}
private func workbenchRowCard(_ row: VelocityImportWorkbenchRowDTO) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text(row.rowNumber.map { "Row \($0)" } ?? "Proposal \(row.proposalId.prefix(8))")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Spacer()
Text("\(confidencePercent(row.confidence))%")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(VelocityTheme.accent)
Text(row.status.replacingOccurrences(of: "_", with: " ").capitalized)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(lifecycleColor(row.status))
}
if !row.validation.isEmpty {
VStack(alignment: .leading, spacing: 5) {
ForEach(row.validation) { issue in
HStack(alignment: .top, spacing: 6) {
Image(systemName: issue.severity == "error" ? "xmark.octagon" : "exclamationmark.triangle")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(issue.severity == "error" ? VelocityTheme.danger : VelocityTheme.warning)
Text("\(issue.field): \(issue.message)")
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.mutedFg)
}
}
}
}
if let duplicate = row.duplicateCandidates.first {
Text("Duplicate candidate: \(duplicate.fullName) · \(duplicate.matchReason) match · \(duplicate.matchScore)%")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(VelocityTheme.warning)
}
let changedDiffs = row.fieldDiffs.filter(\.changed)
if !changedDiffs.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text("Changed fields")
.font(.system(size: 10, weight: .semibold))
.tracking(1)
.foregroundStyle(VelocityTheme.mutedFg)
ForEach(changedDiffs.prefix(4)) { diff in
Text("\(diff.field): \(diff.existing ?? "-")\(diff.proposed ?? "-")")
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.foreground)
.lineLimit(1)
}
}
}
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(VelocityTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(row.validation.contains { $0.severity == "error" } ? VelocityTheme.danger.opacity(0.25) : VelocityTheme.borderSubtle, lineWidth: 1)
)
)
}
private func proposalCard(_ proposal: VelocityImportProposalDTO, batchId: String) -> some View {
let rowDiagnostics = workbench?.row(for: proposal.proposalId)
return VStack(alignment: .leading, spacing: 10) {
HStack {
VStack(alignment: .leading, spacing: 3) {
Text(proposal.rowLabel)
@@ -227,6 +380,16 @@ struct ImportsView: View {
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.danger)
}
if let unresolved = proposal.payload?.unresolvedFields, !unresolved.isEmpty {
Text("Needs review: \(unresolved.joined(separator: ", "))")
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.warning)
}
if let rowDiagnostics {
proposalDiagnostics(rowDiagnostics)
}
}
.padding(14)
.background(
@@ -239,20 +402,49 @@ struct ImportsView: View {
)
}
private func proposalDiagnostics(_ row: VelocityImportWorkbenchRowDTO) -> some View {
VStack(alignment: .leading, spacing: 5) {
if !row.validation.isEmpty {
Text("Validation: \(row.validation.map { "\($0.field) \($0.severity)" }.joined(separator: ", "))")
.font(.system(size: 11))
.foregroundStyle(row.validation.contains { $0.severity == "error" } ? VelocityTheme.danger : VelocityTheme.warning)
}
if let duplicate = row.duplicateCandidates.first {
Text("Possible duplicate: \(duplicate.fullName) (\(duplicate.matchReason), \(duplicate.matchScore)%)")
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.warning)
}
}
}
private func proposalActions(_ proposal: VelocityImportProposalDTO, batchId: String) -> some View {
HStack(spacing: 8) {
Button("Approve") {
Task { await reviewProposal(batchId: batchId, proposal: proposal, decision: "approved") }
Task {
await reviewProposal(
batchId: batchId,
proposal: proposal,
decision: "approved",
duplicatePolicy: defaultDuplicatePolicyValue(for: proposal)
)
}
}
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(VelocityTheme.success)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.contentShape(Rectangle())
.disabled(proposal.status.lowercased() == "approved")
.disabled(proposal.status.lowercased() == "approved" || defaultDuplicatePolicyValue(for: proposal) == nil)
Button("Reject") {
Task { await reviewProposal(batchId: batchId, proposal: proposal, decision: "rejected") }
Task {
await reviewProposal(
batchId: batchId,
proposal: proposal,
decision: "rejected",
duplicatePolicy: defaultDuplicatePolicyValue(for: proposal)
)
}
}
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(VelocityTheme.danger)
@@ -260,9 +452,46 @@ struct ImportsView: View {
.padding(.vertical, 8)
.contentShape(Rectangle())
.disabled(proposal.status.lowercased() == "rejected")
Button("Needs Info") {
Task {
await reviewProposal(
batchId: batchId,
proposal: proposal,
decision: "needs_more_info",
duplicatePolicy: defaultDuplicatePolicyValue(for: proposal)
)
}
}
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(VelocityTheme.warning)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.contentShape(Rectangle())
Button("Remediate") {
remediationDraft = ImportRemediationDraft(
batchId: batchId,
proposal: proposal,
workbenchRow: workbench?.row(for: proposal.proposalId)
)
}
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(VelocityTheme.accent)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.contentShape(Rectangle())
}
}
private func defaultDuplicatePolicyValue(for proposal: VelocityImportProposalDTO) -> String? {
if let policy = workbench?.rows.first(where: { $0.proposalId == proposal.proposalId })?.duplicatePolicy,
appStore.crmVocabularies.importDuplicatePolicies.contains(where: { $0.value == policy }) {
return policy
}
return appStore.crmVocabularies.importDuplicatePolicies.first?.value
}
private func loadBatches(selectFirst: Bool, silent: Bool = false) async {
if !silent {
isLoading = true
@@ -291,6 +520,7 @@ struct ImportsView: View {
await MainActor.run {
selectedBatch = batch
detail = nil
workbench = nil
errorMessage = nil
successMessage = nil
isLoading = true
@@ -303,9 +533,13 @@ struct ImportsView: View {
await MainActor.run { isLoading = true }
}
do {
let fetched = try await VelocityAPIClient.shared.fetchImportBatch(batchId: batchId)
async let detailTask = VelocityAPIClient.shared.fetchImportBatch(batchId: batchId)
async let workbenchTask = VelocityAPIClient.shared.fetchImportWorkbench(batchId: batchId)
let fetched = try await detailTask
let fetchedWorkbench = try? await workbenchTask
await MainActor.run {
detail = fetched
workbench = fetchedWorkbench
errorMessage = nil
isLoading = false
}
@@ -317,11 +551,34 @@ struct ImportsView: View {
}
}
private func refreshWorkbench(batchId: String) async {
do {
let fetchedWorkbench = try await VelocityAPIClient.shared.fetchImportWorkbench(batchId: batchId)
await MainActor.run {
workbench = fetchedWorkbench
errorMessage = nil
}
} catch {
await MainActor.run {
errorMessage = error.localizedDescription
}
}
}
private func reviewProposal(
batchId: String,
proposal: VelocityImportProposalDTO,
decision: String
decision: String,
notes: String = "Reviewed from iPad Imports workspace.",
fieldOverrides: [String: String] = [:],
duplicatePolicy: String?
) async {
guard let duplicatePolicy else {
await MainActor.run {
errorMessage = "Unable to review import row because backend duplicate policy vocabulary is unavailable."
}
return
}
await MainActor.run {
activeProposalID = proposal.proposalId
errorMessage = nil
@@ -332,12 +589,15 @@ struct ImportsView: View {
batchId: batchId,
proposalId: proposal.proposalId,
decision: decision,
notes: "Reviewed from iPad Imports workspace."
notes: notes,
fieldOverrides: fieldOverrides,
duplicatePolicy: duplicatePolicy
)
await refreshDetail(batchId: batchId, silent: true)
await MainActor.run {
activeProposalID = nil
successMessage = "Proposal \(decision)."
remediationDraft = nil
successMessage = "Proposal \(decision.replacingOccurrences(of: "_", with: " "))."
}
} catch {
await MainActor.run {
@@ -379,6 +639,11 @@ struct ImportsView: View {
detail.proposals.filter { $0.status == "approved" }.count
}
private func confidencePercent(_ value: Double) -> Int {
let normalized = value <= 1 ? value * 100 : value
return max(0, min(100, Int(normalized.rounded())))
}
private func canonicalPreview(_ payload: [String: JSONValue]) -> String {
payload
.sorted(by: { $0.key < $1.key })
@@ -462,6 +727,239 @@ struct ImportsView: View {
}
}
private struct ImportRemediationSheet: View {
let draft: ImportRemediationDraft
let duplicatePolicies: [VelocityVocabularyOptionDTO]
let onSubmit: (String, String, [String: String], String) -> Void
@Environment(\.dismiss) private var dismiss
@State private var notes: String
@State private var fieldOverrides: [String: String]
@State private var duplicatePolicy: String
init(
draft: ImportRemediationDraft,
duplicatePolicies: [VelocityVocabularyOptionDTO],
onSubmit: @escaping (String, String, [String: String], String) -> Void
) {
self.draft = draft
self.duplicatePolicies = duplicatePolicies
self.onSubmit = onSubmit
_notes = State(initialValue: "")
let canonicalPayload = draft.proposal.payload?.canonicalPayload ?? [:]
let initialOverrides: [String: String] = Dictionary<String, String>(
uniqueKeysWithValues: draft.fields.map { field in
(field, canonicalPayload[field]?.stringValue ?? "")
}
)
_fieldOverrides = State(initialValue: initialOverrides)
let initialPolicy = duplicatePolicies.first(where: { $0.value == draft.workbenchRow?.duplicatePolicy })?.value
?? duplicatePolicies.first?.value
?? ""
_duplicatePolicy = State(initialValue: initialPolicy)
}
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
VStack(alignment: .leading, spacing: 6) {
Text(draft.proposal.rowLabel)
.font(.system(size: 24, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text("\(draft.proposal.confidencePercent)% confidence · \(draft.proposal.status.replacingOccurrences(of: "_", with: " ").capitalized)")
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.mutedFg)
}
if let workbenchRow = draft.workbenchRow {
remediationDiagnostics(workbenchRow)
duplicatePolicyPicker(workbenchRow)
}
if draft.fields.isEmpty {
Text("No canonical fields were returned for this proposal.")
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.mutedFg)
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(RoundedRectangle(cornerRadius: 14).fill(VelocityTheme.surface))
} else {
VStack(alignment: .leading, spacing: 12) {
Text("Field Corrections")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
ForEach(draft.fields, id: \.self) { field in
VStack(alignment: .leading, spacing: 7) {
Text(field.replacingOccurrences(of: "_", with: " ").uppercased())
.font(.system(size: 10, weight: .semibold))
.tracking(1.2)
.foregroundStyle(VelocityTheme.mutedFg)
TextField(field, text: Binding(
get: { fieldOverrides[field] ?? "" },
set: { fieldOverrides[field] = $0 }
))
.textFieldStyle(.plain)
.font(.system(size: 14))
.foregroundStyle(VelocityTheme.foreground)
.padding(12)
.background(RoundedRectangle(cornerRadius: 12).fill(VelocityTheme.surface))
}
}
}
}
VStack(alignment: .leading, spacing: 7) {
Text("Review Notes")
.font(.system(size: 10, weight: .semibold))
.tracking(1.2)
.foregroundStyle(VelocityTheme.mutedFg)
TextEditor(text: $notes)
.frame(minHeight: 90)
.font(.system(size: 14))
.foregroundStyle(VelocityTheme.foreground)
.scrollContentBackground(.hidden)
.padding(10)
.background(RoundedRectangle(cornerRadius: 12).fill(VelocityTheme.surface))
}
}
.padding(24)
}
.background(VelocityTheme.background)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItemGroup(placement: .confirmationAction) {
Button("Needs Info") {
submit("needs_more_info")
}
Button("Approve Corrected") {
submit("approved")
}
.fontWeight(.semibold)
}
}
}
.presentationDetents([.large])
}
private func remediationDiagnostics(_ row: VelocityImportWorkbenchRowDTO) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text("Validation and Duplicate Preview")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
if row.validation.isEmpty && row.duplicateCandidates.isEmpty && row.fieldDiffs.filter(\.changed).isEmpty {
Text("No validation issues, duplicate candidates, or canonical row diffs were returned for this proposal.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
ForEach(row.validation) { issue in
HStack(alignment: .top, spacing: 8) {
Image(systemName: issue.severity == "error" ? "xmark.octagon" : "exclamationmark.triangle")
.foregroundStyle(issue.severity == "error" ? VelocityTheme.danger : VelocityTheme.warning)
Text("\(issue.field): \(issue.message)")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
}
ForEach(row.duplicateCandidates.prefix(3)) { candidate in
VStack(alignment: .leading, spacing: 4) {
Text("\(candidate.fullName) · \(candidate.matchReason) match · \(candidate.matchScore)%")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(VelocityTheme.warning)
Text([candidate.primaryPhone, candidate.primaryEmail].compactMap { $0?.trimmedNonEmpty }.joined(separator: " · "))
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.mutedFg)
}
}
ForEach(row.fieldDiffs.filter(\.changed).prefix(6)) { diff in
Text("\(diff.field): \(diff.existing ?? "-")\(diff.proposed ?? "-")")
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.foreground)
}
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(RoundedRectangle(cornerRadius: 14).fill(VelocityTheme.surface))
}
private func duplicatePolicyPicker(_ row: VelocityImportWorkbenchRowDTO) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text("Duplicate Merge Policy")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Picker("Duplicate policy", selection: $duplicatePolicy) {
ForEach(duplicatePolicyOptions()) { policy in
Text(policy.label).tag(policy.value)
}
}
.pickerStyle(.segmented)
if let guidance = duplicatePolicyOptions().first(where: { $0.value == duplicatePolicy })?.description?.trimmedNonEmpty {
Text(guidance)
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
if row.duplicateCandidates.isEmpty {
Text("No duplicate candidates were returned by the backend for this row.")
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.mutedFg)
} else if let candidate = row.duplicateCandidates.first {
Text("Strongest candidate: \(candidate.fullName) · \(candidate.matchReason) · \(candidate.matchScore)%")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(VelocityTheme.warning)
}
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(RoundedRectangle(cornerRadius: 14).fill(VelocityTheme.surface))
}
private func duplicatePolicyOptions() -> [VelocityVocabularyOptionDTO] {
guard !duplicatePolicies.contains(where: { $0.value == duplicatePolicy }),
let current = duplicatePolicy.trimmedNonEmpty
else {
return duplicatePolicies
}
return [
VelocityVocabularyOptionDTO(
value: current,
label: current.replacingOccurrences(of: "_", with: " ").capitalized,
description: "Current backend value",
icon: nil
)
] + duplicatePolicies
}
private func submit(_ decision: String) {
let cleanedOverrides = fieldOverrides.compactMapValues { value in
value.trimmedNonEmpty
}
let defaultNote = decision == "needs_more_info"
? "Marked needs more information from iPad Imports remediation."
: "Approved with iPad Imports field corrections."
guard let selectedPolicy = duplicatePolicy.trimmedNonEmpty else {
return
}
onSubmit(decision, notes.trimmedNonEmpty ?? defaultNote, cleanedOverrides, selectedPolicy)
dismiss()
}
}
private extension String {
var trimmedNonEmpty: String? {
let value = trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
}
#Preview {
ImportsView()
}

View File

@@ -3,6 +3,7 @@ import CoreLocation
import CoreMotion
import SceneKit
import SwiftUI
import UIKit
// MARK: - ARSunOverlayView
@@ -22,6 +23,13 @@ struct ARSunOverlayView: UIViewRepresentable {
let config = ARWorldTrackingConfiguration()
config.worldAlignment = .gravityAndHeading // north = -Z axis
config.planeDetection = [.horizontal, .vertical]
if ARWorldTrackingConfiguration.supportsSceneReconstruction(.mesh) {
config.sceneReconstruction = .mesh
}
if ARWorldTrackingConfiguration.supportsFrameSemantics(.sceneDepth) {
config.frameSemantics.insert(.sceneDepth)
}
view.session.run(config)
context.coordinator.attach(to: view)
@@ -46,7 +54,9 @@ struct ARSunOverlayView: UIViewRepresentable {
// Scene node containers (replaced on each rebuild)
private var arcRootNode = SCNNode()
private var currentSunNode = SCNNode()
private var measurementRootNode = SCNNode()
private var isSceneBuilt = false
private var pendingMeasurementPoint: SCNVector3?
// Fallback timer for CoreMotion-only mode
private var fallbackTimer: Timer?
@@ -61,6 +71,10 @@ struct ARSunOverlayView: UIViewRepresentable {
self.sceneView = sceneView
sceneView.scene.rootNode.addChildNode(arcRootNode)
sceneView.scene.rootNode.addChildNode(currentSunNode)
sceneView.scene.rootNode.addChildNode(measurementRootNode)
let tap = UITapGestureRecognizer(target: self, action: #selector(handleMeasurementTap(_:)))
sceneView.addGestureRecognizer(tap)
}
func stop() {
@@ -107,6 +121,59 @@ struct ARSunOverlayView: UIViewRepresentable {
}
}
// MARK: - Measurement
@objc private func handleMeasurementTap(_ recognizer: UITapGestureRecognizer) {
guard let sceneView else { return }
let point = recognizer.location(in: sceneView)
guard let query = sceneView.raycastQuery(
from: point,
allowing: .estimatedPlane,
alignment: .any
),
let result = sceneView.session.raycast(query).first else { return }
let transform = result.worldTransform
let worldPoint = SCNVector3(transform.columns.3.x, transform.columns.3.y, transform.columns.3.z)
addMeasurementPoint(worldPoint)
}
private func addMeasurementPoint(_ point: SCNVector3) {
measurementRootNode.addChildNode(makeMeasurementMarker(at: point))
if let start = pendingMeasurementPoint {
let distance = start.distance(to: point)
measurementRootNode.addChildNode(makeLineNode(through: [start, point], color: UIColor.white.withAlphaComponent(0.82)))
let midpoint = SCNVector3(
(start.x + point.x) / 2,
(start.y + point.y) / 2 + 0.045,
(start.z + point.z) / 2
)
let label = makeTextNode(
text: "\(String(format: "%.2f m", Double(distance))) \(String(format: "%.1f ft", Double(distance * 3.28084)))",
color: .white,
fontSize: 0.052
)
label.position = midpoint
measurementRootNode.addChildNode(label)
pendingMeasurementPoint = nil
UIImpactFeedbackGenerator(style: .rigid).impactOccurred()
} else {
pendingMeasurementPoint = point
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
}
}
private func makeMeasurementMarker(at position: SCNVector3) -> SCNNode {
let sphere = SCNSphere(radius: 0.018)
sphere.firstMaterial?.diffuse.contents = UIColor.white
sphere.firstMaterial?.emission.contents = UIColor.systemBlue.withAlphaComponent(0.65)
sphere.firstMaterial?.lightingModel = .constant
let node = SCNNode(geometry: sphere)
node.position = position
return node
}
// MARK: - Scene Building
private func buildScene() {
@@ -274,3 +341,12 @@ struct ARSunOverlayView: UIViewRepresentable {
private extension Double {
var radians: Double { self * .pi / 180.0 }
}
private extension SCNVector3 {
func distance(to other: SCNVector3) -> Float {
let dx = other.x - x
let dy = other.y - y
let dz = other.z - z
return sqrtf(dx * dx + dy * dy + dz * dz)
}
}

View File

@@ -13,11 +13,7 @@ enum InventoryModeAvailability {
}
static func productionVisibleModes(hasDollhouseAsset: Bool) -> [InventoryStore.Mode] {
var modes: [InventoryStore.Mode] = [.sunseeker, .dreamWeaver]
if hasDollhouseAsset {
modes.append(.dollhouse)
}
return modes
[.sunseeker, .dreamWeaver]
}
static func sanitizedProductionSelection(
@@ -28,8 +24,9 @@ enum InventoryModeAvailability {
}
static func modeSummaryText(hasDollhouseAsset: Bool) -> String {
productionVisibleModes(hasDollhouseAsset: hasDollhouseAsset)
.map(\.rawValue)
.joined(separator: " · ")
let base = productionVisibleModes(hasDollhouseAsset: hasDollhouseAsset).map(\.rawValue)
return hasDollhouseAsset
? (base + ["Map-to-Dollhouse"]).joined(separator: " · ")
: base.joined(separator: " · ")
}
}

View File

@@ -1,252 +1,18 @@
import CoreLocation
import SceneKit
import SwiftUI
#if targetEnvironment(simulator)
/// A non-AR 3D view for testing the Sunseeker path logic on the iOS Simulator.
/// Uses a synthetic camera, fake location, and mock heading instead of ARKit.
struct SimulatorSunOverlayView: UIViewRepresentable {
struct SimulatorSunOverlayView: View {
@Binding var sunNodesReady: Bool
// Fake location (e.g. San Francisco)
private let mockLocation = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194)
private let mockHeading: Double = 0 // North
func makeCoordinator() -> Coordinator {
Coordinator(sunNodesReady: $sunNodesReady, mockLocation: mockLocation, mockHeading: mockHeading)
}
func makeUIView(context: Context) -> SCNView {
let view = SCNView(frame: .zero)
view.scene = SCNScene()
view.allowsCameraControl = true // Swipe around the 3D space
view.autoenablesDefaultLighting = true
view.backgroundColor = UIColor(white: 0.1, alpha: 1.0)
view.isPlaying = true // Force render loop
view.showsStatistics = true // Prove it's rendering
// Setup synthetic camera
let cameraNode = SCNNode()
cameraNode.camera = SCNCamera()
cameraNode.camera?.zFar = 100
cameraNode.position = SCNVector3(x: 0, y: 0, z: 0) // Centered
view.scene?.rootNode.addChildNode(cameraNode)
context.coordinator.attach(to: view)
return view
}
func updateUIView(_ uiView: SCNView, context: Context) {}
final class Coordinator: NSObject {
@Binding private var sunNodesReady: Bool
private let mockLocation: CLLocationCoordinate2D
private let mockHeading: Double
private var arcRootNode = SCNNode()
private var currentSunNode = SCNNode()
private var updateTimer: Timer?
init(sunNodesReady: Binding<Bool>, mockLocation: CLLocationCoordinate2D, mockHeading: Double) {
_sunNodesReady = sunNodesReady
self.mockLocation = mockLocation
self.mockHeading = mockHeading
super.init()
}
func attach(to view: SCNView) {
view.scene?.rootNode.addChildNode(arcRootNode)
view.scene?.rootNode.addChildNode(currentSunNode)
buildScene()
startRealTimeTick()
}
deinit {
updateTimer?.invalidate()
}
private func startRealTimeTick() {
// Update current sun position every second
updateTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
guard let self = self else { return }
let cur = SunMath.calculateSunPosition(date: Date(), coordinate: self.mockLocation)
// Need to remove previous child as we are completely replacing it
self.currentSunNode.childNodes.forEach { $0.removeFromParentNode() }
let radius: Float = 1.8
let orb = SCNSphere(radius: 0.055)
orb.firstMaterial?.diffuse.contents = UIColor.systemOrange
orb.firstMaterial?.emission.contents = UIColor.systemYellow
orb.firstMaterial?.lightingModel = .constant
let orbNode = SCNNode(geometry: orb)
orbNode.position = self.worldPosition(for: cur, radius: radius)
let pulse = CABasicAnimation(keyPath: "scale")
pulse.fromValue = SCNVector3(1, 1, 1)
pulse.toValue = SCNVector3(1.3, 1.3, 1.3)
pulse.duration = 1.2
pulse.autoreverses = true
pulse.repeatCount = .infinity
orbNode.addAnimation(pulse, forKey: "pulse")
let label = self.makeTextNode(text: "Now", color: .systemYellow, fontSize: 0.05)
label.position = SCNVector3(0, 0.09, 0)
orbNode.addChildNode(label)
self.currentSunNode.addChildNode(orbNode)
}
}
private func buildScene() {
let arc = SunMath.sunPathArc(for: Date(), coordinate: mockLocation)
let riseSet = SunMath.sunRiseSet(for: Date(), coordinate: mockLocation)
let radius: Float = 1.8
var positions: [SCNVector3] = []
// Hourly blocks
for (date, pos) in arc {
guard pos.elevation > -5 else { continue }
let worldPos = worldPosition(for: pos, radius: radius)
positions.append(worldPos)
let sphere = SCNSphere(radius: 0.018)
sphere.firstMaterial?.diffuse.contents = UIColor.systemYellow.withAlphaComponent(0.85)
sphere.firstMaterial?.lightingModel = .constant
let markerNode = SCNNode(geometry: sphere)
markerNode.position = worldPos
arcRootNode.addChildNode(markerNode)
let calendar = Calendar.current
let hour = calendar.component(.hour, from: date)
if hour % 2 == 0 {
let labelNode = makeTextNode(text: hourLabel(from: date), color: .white, fontSize: 0.04)
labelNode.position = SCNVector3(worldPos.x, worldPos.y + 0.06, worldPos.z)
arcRootNode.addChildNode(labelNode)
}
}
if positions.count >= 2 {
let lineNode = makeLineNode(through: positions, color: UIColor.systemYellow.withAlphaComponent(0.55))
arcRootNode.addChildNode(lineNode)
}
if let riseDate = riseSet.rise {
let risePos = SunMath.calculateSunPosition(date: riseDate, coordinate: mockLocation)
let wPos = worldPosition(for: risePos, radius: radius)
arcRootNode.addChildNode(makeSpecialMarker(at: wPos, color: .systemOrange, label: "Sunrise \(hourLabel(from: riseDate))"))
}
if let setDate = riseSet.set {
let setPos = SunMath.calculateSunPosition(date: setDate, coordinate: mockLocation)
let wPos = worldPosition(for: setPos, radius: radius)
arcRootNode.addChildNode(makeSpecialMarker(at: wPos, color: .systemRed, label: "Sunset \(hourLabel(from: setDate))"))
}
// Generate current sun node synchronously for first frame
updateTimer?.fire()
DispatchQueue.main.async {
self.sunNodesReady = true
}
}
// MARK: Math equivalent from SunseekerViewModel
private func worldPosition(for sun: SunPosition, radius: Float) -> SCNVector3 {
let elev = Float(sun.elevation * .pi / 180.0)
let az = Float(sun.azimuth * .pi / 180.0)
let x = radius * cos(elev) * sin(az)
let y = radius * sin(elev)
let z = -radius * cos(elev) * cos(az)
return SCNVector3(x, y, z)
}
// MARK: SceneKit Factories
private func makeSpecialMarker(at pos: SCNVector3, color: UIColor, label: String) -> SCNNode {
let root = SCNNode()
let sphere = SCNSphere(radius: 0.035)
sphere.firstMaterial?.diffuse.contents = color
sphere.firstMaterial?.emission.contents = color.withAlphaComponent(0.5)
sphere.firstMaterial?.lightingModel = .constant
let markerNode = SCNNode(geometry: sphere)
markerNode.position = pos
root.addChildNode(markerNode)
let labelNode = makeTextNode(text: label, color: .white, fontSize: 0.04)
labelNode.position = SCNVector3(pos.x, pos.y + 0.07, pos.z)
root.addChildNode(labelNode)
return root
}
private func makeTextNode(text: String, color: UIColor, fontSize: CGFloat) -> SCNNode {
// SCNText is buggy in Simulator. Render text to a UIImage instead.
let font = UIFont.systemFont(ofSize: 40, weight: .bold)
let attributes: [NSAttributedString.Key: Any] = [
.font: font,
.foregroundColor: color
]
let size = (text as NSString).size(withAttributes: attributes)
// Add some padding
let paddedSize = CGSize(width: size.width + 10, height: size.height + 10)
let renderer = UIGraphicsImageRenderer(size: paddedSize)
let image = renderer.image { context in
(text as NSString).draw(
in: CGRect(x: 5, y: 5, width: size.width, height: size.height),
withAttributes: attributes
)
}
// Map the image onto an SCNPlane
// A 100x50 image becomes a 0.1 x 0.05 meter plane
let plane = SCNPlane(width: paddedSize.width / 1000.0, height: paddedSize.height / 1000.0)
plane.firstMaterial?.diffuse.contents = image
plane.firstMaterial?.isDoubleSided = true
plane.firstMaterial?.lightingModel = .constant
let textNode = SCNNode(geometry: plane)
// Statically scale the plane up so it is readable next to the spheres
textNode.scale = SCNVector3(1.5, 1.5, 1.5)
let constraint = SCNBillboardConstraint()
constraint.freeAxes = .all
textNode.constraints = [constraint]
return textNode
}
private func makeLineNode(through positions: [SCNVector3], color: UIColor) -> SCNNode {
guard positions.count >= 2 else { return SCNNode() }
let vertices: [SCNVector3] = positions
var indices: [Int32] = []
for i in 0..<(vertices.count - 1) {
indices.append(Int32(i))
indices.append(Int32(i + 1))
}
let vertexSource = SCNGeometrySource(vertices: vertices)
let element = SCNGeometryElement(
indices: indices,
primitiveType: .line
)
let geometry = SCNGeometry(sources: [vertexSource], elements: [element])
geometry.firstMaterial?.diffuse.contents = color
geometry.firstMaterial?.lightingModel = .constant
return SCNNode(geometry: geometry)
}
private func hourLabel(from date: Date) -> String {
let fmt = DateFormatter()
fmt.dateFormat = "ha"
fmt.amSymbol = "am"
fmt.pmSymbol = "pm"
return fmt.string(from: date)
var body: some View {
ContentUnavailableView(
"Sunseeker Unavailable",
systemImage: "arkit",
description: Text("Run on a physical iPad to use live location, heading, and ARKit camera data.")
)
.onAppear {
sunNodesReady = false
}
}
}

View File

@@ -2,18 +2,15 @@ import Foundation
enum OracleModeAvailability {
static let productionVisibleModes: [OracleMode] = [
.pipeline,
.deals,
.accountTimeline,
.calendarTasks,
]
static let hiddenModesUntilBackendSupport: [OracleMode] = [
.teamPerformance,
.leadMap,
]
static let hiddenModesUntilBackendSupport: [OracleMode] = [
]
static func sanitizedProductionSelection(_ candidate: OracleMode) -> OracleMode {
productionVisibleModes.contains(candidate) ? candidate : .pipeline
productionVisibleModes.contains(candidate) ? candidate : .accountTimeline
}
}

View File

@@ -1,4 +1,6 @@
import Combine
import AVFoundation
import AudioToolbox
import Speech
import SwiftUI
enum OracleMode: String, CaseIterable {
@@ -27,9 +29,180 @@ enum OracleMode: String, CaseIterable {
}
}
struct OracleConciergeSheet: View {
@State private var transcript = ""
@State private var resultText = ""
@State private var errorText: String?
@State private var isRecording = false
@State private var isQuerying = false
@State private var speechAuthorized = false
@State private var audioEngine = AVAudioEngine()
@State private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
@State private var recognitionTask: SFSpeechRecognitionTask?
private let recognizer = SFSpeechRecognizer()
var body: some View {
VStack(spacing: 0) {
VStack(alignment: .leading, spacing: 14) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Oracle Concierge")
.font(.system(size: 26, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text("Push to talk. Live query routes to `/api/oracle/query`.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
Button {
isRecording ? stopRecordingAndQuery() : startRecording()
} label: {
Image(systemName: isRecording ? "stop.fill" : "mic.fill")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 54, height: 54)
.background(Circle().fill(isRecording ? VelocityTheme.danger : VelocityTheme.accent))
.shadow(color: (isRecording ? VelocityTheme.danger : VelocityTheme.accent).opacity(0.45), radius: isRecording ? 18 : 10)
}
.buttonStyle(.plain)
.disabled(!speechAuthorized || isQuerying)
}
if !transcript.isEmpty {
Text(transcript)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(VelocityTheme.foreground)
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(RoundedRectangle(cornerRadius: 14).fill(VelocityTheme.surface))
.transition(.opacity.combined(with: .scale))
}
if isQuerying {
ProgressView("Asking Oracle...")
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
.foregroundStyle(VelocityTheme.mutedFg)
} else if !resultText.isEmpty {
Text(resultText)
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.foreground)
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(RoundedRectangle(cornerRadius: 14).fill(VelocityTheme.accent.opacity(0.10)))
.transition(.move(edge: .top).combined(with: .opacity))
}
if let errorText {
Text(errorText)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(VelocityTheme.danger)
}
}
.padding(24)
Divider().background(VelocityTheme.borderSubtle)
OracleView()
}
.background(VelocityTheme.background)
.task { await requestSpeechAuthorization() }
.animation(.interactiveSpring(response: 0.42, dampingFraction: 0.86), value: isRecording)
.animation(.interactiveSpring(response: 0.42, dampingFraction: 0.86), value: resultText)
}
private func requestSpeechAuthorization() async {
let status = await withCheckedContinuation { continuation in
SFSpeechRecognizer.requestAuthorization { continuation.resume(returning: $0) }
}
await MainActor.run {
speechAuthorized = status == .authorized
if !speechAuthorized {
errorText = "Speech recognition permission is required for voice Oracle."
}
}
}
private func startRecording() {
recognitionTask?.cancel()
recognitionTask = nil
transcript = ""
resultText = ""
errorText = nil
let session = AVAudioSession.sharedInstance()
do {
try session.setCategory(.record, mode: .measurement, options: .duckOthers)
try session.setActive(true, options: .notifyOthersOnDeactivation)
let request = SFSpeechAudioBufferRecognitionRequest()
request.shouldReportPartialResults = true
recognitionRequest = request
let inputNode = audioEngine.inputNode
let format = inputNode.outputFormat(forBus: 0)
inputNode.removeTap(onBus: 0)
inputNode.installTap(onBus: 0, bufferSize: 1024, format: format) { buffer, _ in
request.append(buffer)
}
audioEngine.prepare()
try audioEngine.start()
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.82)) {
isRecording = true
}
recognitionTask = recognizer?.recognitionTask(with: request) { result, error in
Task { @MainActor in
if let result {
transcript = result.bestTranscription.formattedString
}
if let error {
errorText = error.localizedDescription
stopRecording()
}
}
}
} catch {
errorText = error.localizedDescription
stopRecording()
}
}
private func stopRecordingAndQuery() {
stopRecording()
let prompt = transcript.trimmingCharacters(in: .whitespacesAndNewlines)
guard !prompt.isEmpty else { return }
Task { await queryOracle(prompt) }
}
private func stopRecording() {
audioEngine.stop()
audioEngine.inputNode.removeTap(onBus: 0)
recognitionRequest?.endAudio()
recognitionTask?.cancel()
recognitionRequest = nil
recognitionTask = nil
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.82)) {
isRecording = false
}
}
@MainActor
private func queryOracle(_ prompt: String) async {
isQuerying = true
errorText = nil
do {
let response = try await VelocityAPIClient.shared.queryOracle(prompt: prompt)
resultText = response.displaySummary
} catch {
errorText = error.localizedDescription
}
isQuerying = false
}
}
struct OracleView: View {
@State private var store = AppStore.shared
@State private var selectedMode: OracleMode = OracleModeAvailability.sanitizedProductionSelection(.pipeline)
@State private var selectedMode: OracleMode = OracleModeAvailability.sanitizedProductionSelection(.accountTimeline)
@State private var selectedClient360: VelocityClient360DTO?
@State private var selectedClient360PersonID: String?
@State private var isClient360Loading = false
@@ -39,7 +212,11 @@ struct OracleView: View {
@State private var activeTaskMutationID: String?
@State private var activeLeadMutationID: String?
@State private var activeOpportunityMutationID: String?
private let refreshTimer = Timer.publish(every: 20, on: .main, in: .common).autoconnect()
@State private var editingOpportunity: VelocityOpportunityDTO?
@State private var teamPerformance: VelocityOracleTeamPerformanceDTO?
@State private var leadMap: VelocityOracleLeadMapDTO?
@State private var isOracleInsightLoading = false
@State private var oracleInsightError: String?
var body: some View {
VStack(alignment: .leading, spacing: 0) {
@@ -77,17 +254,39 @@ struct OracleView: View {
}
}
.background(VelocityTheme.background)
.task { await store.refresh() }
.refreshable { await store.refresh() }
.task {
await store.refresh()
await loadOracleInsightData(for: selectedMode)
}
.refreshable {
await store.refresh()
await loadOracleInsightData(for: selectedMode)
}
.onAppear {
selectedMode = OracleModeAvailability.sanitizedProductionSelection(selectedMode)
}
.onReceive(refreshTimer) { _ in
Task { await store.refresh(silent: true) }
.onChange(of: selectedMode) { _, mode in
Task { await loadOracleInsightData(for: mode) }
}
.sheet(isPresented: client360PresentationBinding) {
client360Sheet
}
.sheet(item: $editingOpportunity) { opportunity in
OpportunityEditSheet(
opportunity: opportunity,
stages: store.crmVocabularies.opportunityStages
) { stage, value, probability, expectedCloseDate, nextAction, notes in
saveOpportunityEdits(
opportunity,
stage: stage,
value: value,
probability: probability,
expectedCloseDate: expectedCloseDate,
nextAction: nextAction,
notes: notes
)
}
}
}
private var header: some View {
@@ -96,7 +295,7 @@ struct OracleView: View {
Text("Oracle")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text("Live sales intelligence assembled from canonical CRM, communication events, and calendar data.")
Text("Ambient sales intelligence assembled from canonical CRM, communication events, and calendar data.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
@@ -148,11 +347,10 @@ struct OracleView: View {
timelineCanvas
case .calendarTasks:
calendarCanvas
case .teamPerformance, .leadMap:
unavailableCanvas(
title: "Oracle mode unavailable",
message: "This Oracle mode is intentionally hidden from the production iPad scope until a real backend contract exists."
)
case .teamPerformance:
teamPerformanceCanvas
case .leadMap:
leadMapCanvas
}
}
@@ -217,9 +415,15 @@ struct OracleView: View {
.buttonStyle(.plain)
VStack(alignment: .trailing, spacing: 8) {
Text("\(lead.displayIntentScore)")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(VelocityTheme.accent)
if store.isShowroomModeEnabled {
Image(systemName: "eye.slash")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(VelocityTheme.warning)
} else {
Text("\(lead.displayIntentScore)")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(VelocityTheme.accent)
}
if activeLeadMutationID == lead.leadId {
ProgressView()
@@ -382,6 +586,9 @@ struct OracleView: View {
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Spacer()
if store.pendingSyncCalendarEventIDs.contains(event.calendarEventId) || event.calendarEventId.hasPrefix("local-") {
pendingSyncBadge
}
Text(event.status.capitalized)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(color(for: event.status))
@@ -424,16 +631,21 @@ struct OracleView: View {
Text("\(task.ownerLabel) · \(task.clientPhone ?? "No phone")")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
Text(taskNote(task))
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
.lineLimit(2)
if !store.isShowroomModeEnabled {
Text(taskNote(task))
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
.lineLimit(2)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
VStack(alignment: .trailing, spacing: 8) {
if store.pendingSyncTaskIDs.contains(task.reminderId) {
pendingSyncBadge
}
Text(task.priorityLabel)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(taskColor(for: task.priority))
@@ -461,7 +673,7 @@ struct OracleView: View {
Text("Mobile Oracle Scope")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text("This production iPad build shows only live-backed Oracle views. Team Performance and Lead Map stay hidden until the backend exposes real mobile contracts.")
Text("This production iPad build shows live-backed Oracle views only. Team Performance and Lead Map now read dedicated mobile Oracle contracts instead of synthetic local projections.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
@@ -470,6 +682,13 @@ struct OracleView: View {
.glassCard(cornerRadius: 16)
}
private var pendingSyncBadge: some View {
Circle()
.fill(VelocityTheme.warning)
.frame(width: 8, height: 8)
.shadow(color: VelocityTheme.warning.opacity(0.75), radius: 6)
}
private func unavailableCanvas(title: String, message: String) -> some View {
VStack(alignment: .leading, spacing: 16) {
summaryCard(title: title, body: message)
@@ -485,18 +704,16 @@ struct OracleView: View {
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(18)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.glassCard(cornerRadius: 18)
}
private func emptyCard(_ message: String) -> some View {
Text(message)
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.mutedFg)
.padding(18)
.padding(.vertical, 14)
.frame(maxWidth: .infinity, alignment: .leading)
.glassCard(cornerRadius: 18)
}
private func color(for status: String) -> Color {
@@ -533,74 +750,198 @@ struct OracleView: View {
return formatter.string(from: start)
}
private var teamPerformanceCanvas: some View {
VStack(alignment: .leading, spacing: 16) {
productionScopeNote
summaryCard(
title: "Team Performance",
body: "Broker performance is read from canonical users, leads, opportunities, reminders, and interaction activity through `/api/oracle/v1/mobile/team-performance`."
)
if isOracleInsightLoading && teamPerformance == nil {
progressCard("Loading team performance...")
} else if let oracleInsightError {
errorBanner(oracleInsightError)
} else if let teamPerformance, !teamPerformance.performers.isEmpty {
HStack(spacing: 12) {
metricPill("Members", "\(teamPerformance.summary.teamMembers)")
metricPill("Assigned", "\(teamPerformance.summary.assignedLeads)")
metricPill("Open Tasks", "\(teamPerformance.summary.openTasks)")
metricPill("Pipeline", moneyLabel(teamPerformance.summary.pipelineValue))
}
ForEach(teamPerformance.performers) { performer in
VStack(alignment: .leading, spacing: 10) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(performer.name)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text(performer.email ?? "No email")
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
Text("\(Int(performer.conversionRate.rounded()))%")
.font(.system(size: 13, weight: .bold))
.foregroundStyle(VelocityTheme.success)
}
HStack(spacing: 10) {
metricPill("Leads", "\(performer.assignedLeads)")
metricPill("Deals", "\(performer.activeOpportunities)")
metricPill("Tasks", "\(performer.openTasks)")
metricPill("Won", moneyLabel(performer.closedWonValue))
}
Text("Last activity \(performer.lastActivityAt ?? "not recorded")")
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.glassCard(cornerRadius: 16)
}
} else {
emptyCard("No canonical team performance rows are available for this tenant yet.")
}
}
}
private var leadMapCanvas: some View {
VStack(alignment: .leading, spacing: 16) {
productionScopeNote
summaryCard(
title: "Lead Map",
body: "Lead geography is read from the Oracle lead geo rollup when present, with a canonical CRM city rollup fallback when precise coordinates are not stored."
)
if isOracleInsightLoading && leadMap == nil {
progressCard("Loading lead map...")
} else if let oracleInsightError {
errorBanner(oracleInsightError)
} else if let leadMap, !leadMap.points.isEmpty {
HStack(spacing: 12) {
metricPill("Locations", "\(leadMap.summary.locations)")
metricPill("Leads", "\(leadMap.summary.leadCount)")
metricPill("Hot Leads", "\(leadMap.summary.hotLeadCount)")
}
VStack(alignment: .leading, spacing: 10) {
ForEach(leadMap.points) { point in
HStack(spacing: 12) {
Circle()
.fill(point.hotLeadCount > 0 ? VelocityTheme.danger : VelocityTheme.accent)
.frame(width: max(12, min(34, CGFloat(point.leadCount + 10))), height: max(12, min(34, CGFloat(point.leadCount + 10))))
VStack(alignment: .leading, spacing: 4) {
Text(point.label)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text(store.isShowroomModeEnabled ? "\(point.leadCount) leads · buyer-safe" : "\(point.leadCount) leads · \(point.hotLeadCount) hot · QD \(Int((point.avgQdScore * 100).rounded()))")
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
if let latitude = point.latitude, let longitude = point.longitude {
Text(String(format: "%.3f, %.3f", latitude, longitude))
.font(.system(size: 10, weight: .medium))
.foregroundStyle(VelocityTheme.mutedFg)
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(VelocityTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
)
)
}
}
.padding(16)
.glassCard(cornerRadius: 16)
} else {
emptyCard("No canonical location or city-level CRM lead rollups are available yet.")
}
}
}
private func progressCard(_ message: String) -> some View {
HStack(spacing: 10) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
Text(message)
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(.vertical, 14)
.frame(maxWidth: .infinity, alignment: .leading)
}
private func metricPill(_ title: String, _ value: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(title.uppercased())
.font(.system(size: 9, weight: .semibold))
.tracking(0.8)
.foregroundStyle(VelocityTheme.mutedFg)
Text(value)
.font(.system(size: 14, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
}
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
}
private func moneyLabel(_ value: Double) -> String {
if value >= 1_000_000 {
return String(format: "AED %.1fM", value / 1_000_000)
}
if value >= 1_000 {
return String(format: "AED %.0fK", value / 1_000)
}
return String(format: "AED %.0f", value)
}
private func loadOracleInsightData(for mode: OracleMode, silent: Bool = false) async {
guard mode == .teamPerformance || mode == .leadMap else {
return
}
if !silent {
await MainActor.run {
isOracleInsightLoading = true
oracleInsightError = nil
}
}
do {
switch mode {
case .teamPerformance:
let response = try await VelocityAPIClient.shared.fetchOracleTeamPerformance()
await MainActor.run { teamPerformance = response }
case .leadMap:
let response = try await VelocityAPIClient.shared.fetchOracleLeadMap()
await MainActor.run { leadMap = response }
default:
break
}
await MainActor.run {
isOracleInsightLoading = false
oracleInsightError = nil
}
} catch {
await MainActor.run {
isOracleInsightLoading = false
oracleInsightError = error.localizedDescription
}
}
}
private func opportunityActionsMenu(_ opportunity: VelocityOpportunityDTO) -> some View {
Menu {
Menu("Move Stage") {
ForEach(canonicalOpportunityStages.filter { $0 != opportunity.stage.lowercased() }, id: \.self) { stage in
Button {
mutateOpportunity(
opportunity,
stage: stage,
probability: nil,
nextAction: opportunity.nextAction,
notes: "Moved from the iPad Oracle deal workspace."
)
} label: {
Text(stageLabel(stage))
}
}
}
Menu("Set Probability") {
ForEach([25, 50, 75, 90], id: \.self) { probability in
Button {
mutateOpportunity(
opportunity,
stage: nil,
probability: probability,
nextAction: opportunity.nextAction,
notes: "Probability updated from the iPad Oracle deal workspace."
)
} label: {
Text("\(probability)%")
}
}
}
Button {
mutateOpportunity(
opportunity,
stage: nil,
probability: nil,
nextAction: "Schedule commercial follow-up",
notes: "Next action updated from the iPad Oracle deal workspace."
)
editingOpportunity = opportunity
} label: {
Label("Set Follow-Up Action", systemImage: "phone.arrow.up.right")
}
Button {
mutateOpportunity(
opportunity,
stage: "closed_won",
probability: 100,
nextAction: "Complete booking documentation",
notes: "Marked closed won from the iPad Oracle deal workspace."
)
} label: {
Label("Close Won", systemImage: "checkmark.seal")
}
Button(role: .destructive) {
mutateOpportunity(
opportunity,
stage: "closed_lost",
probability: 0,
nextAction: "Capture loss reason",
notes: "Marked closed lost from the iPad Oracle deal workspace."
)
} label: {
Label("Close Lost", systemImage: "xmark.seal")
Label("Edit Deal", systemImage: "square.and.pencil")
}
} label: {
menuIcon("ellipsis.circle")
@@ -669,16 +1010,16 @@ struct OracleView: View {
currentStatus: String
) -> some View {
Menu {
ForEach(canonicalLeadStages.filter { $0 != currentStatus.lowercased() }, id: \.self) { status in
ForEach(leadStageOptions(currentStatus: currentStatus)) { stage in
Button {
mutateLeadStage(
leadId: leadId,
personId: personId,
status: status,
status: stage.value,
notes: "Moved from the iPad Oracle pipeline."
)
} label: {
Text(stageLabel(status))
Text(stage.label)
}
}
} label: {
@@ -773,6 +1114,47 @@ struct OracleView: View {
await MainActor.run {
activeOpportunityMutationID = nil
actionMessage = opportunityActionMessage(stage: stage, probability: probability, nextAction: nextAction)
rewardClosedWonIfNeeded(stage)
}
} catch {
await MainActor.run {
activeOpportunityMutationID = nil
actionError = error.localizedDescription
}
}
}
}
private func saveOpportunityEdits(
_ opportunity: VelocityOpportunityDTO,
stage: String?,
value: Double?,
probability: Int?,
expectedCloseDate: String?,
nextAction: String?,
notes: String?
) {
actionError = nil
actionMessage = nil
activeOpportunityMutationID = opportunity.opportunityId
Task {
do {
_ = try await store.updateOpportunity(
opportunityId: opportunity.opportunityId,
stage: stage,
value: value,
probability: probability,
expectedCloseDate: expectedCloseDate,
nextAction: nextAction,
notes: notes
)
await refreshClient360IfNeeded(for: opportunity.personId ?? selectedClient360PersonID)
await MainActor.run {
activeOpportunityMutationID = nil
editingOpportunity = nil
actionMessage = "Opportunity updated."
rewardClosedWonIfNeeded(stage)
}
} catch {
await MainActor.run {
@@ -801,39 +1183,21 @@ struct OracleView: View {
}
}
private var canonicalLeadStages: [String] {
[
"new",
"contacted",
"qualified",
"site_visit_scheduled",
"site_visited",
"negotiation",
"booking_initiated",
"booked",
"lost",
"dormant",
]
}
private var canonicalOpportunityStages: [String] {
[
"prospect",
"qualified",
"proposal",
"site_visit",
"negotiation",
"booking",
"agreement",
"closed_won",
"closed_lost",
]
private func leadStageOptions(currentStatus: String) -> [VelocityVocabularyOptionDTO] {
store.crmVocabularies.leadStages.filter { $0.value != currentStatus.lowercased() }
}
private func stageLabel(_ status: String) -> String {
status.replacingOccurrences(of: "_", with: " ").capitalized
}
private func rewardClosedWonIfNeeded(_ stage: String?) {
let normalized = stage?.lowercased().replacingOccurrences(of: " ", with: "_") ?? ""
guard normalized.contains("closed_won") || normalized == "won" else { return }
UIImpactFeedbackGenerator(style: .heavy).impactOccurred(intensity: 1.0)
AudioServicesPlaySystemSound(1104)
}
private func iso8601Timestamp(_ date: Date) -> String {
ISO8601DateFormatter().string(from: date)
}
@@ -1123,17 +1487,17 @@ struct OracleView: View {
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
if let score = snapshot.primaryQDScore {
if !store.isShowroomModeEnabled, let score = snapshot.primaryQDScore {
Text("\(score.scoreType.replacingOccurrences(of: "_", with: " ").capitalized) score: \(score.displayScore)")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.foreground)
}
if !snapshot.riskFlags.isEmpty {
if !store.isShowroomModeEnabled && !snapshot.riskFlags.isEmpty {
Text("Risk flags: \(snapshot.riskFlags.joined(separator: ", "))")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
if !snapshot.recommendedNextActions.isEmpty {
if !store.isShowroomModeEnabled && !snapshot.recommendedNextActions.isEmpty {
Text("Next actions: \(snapshot.recommendedNextActions.joined(separator: " · "))")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
@@ -1218,6 +1582,153 @@ struct OracleView: View {
}
}
private struct OpportunityEditSheet: View {
let opportunity: VelocityOpportunityDTO
let stages: [VelocityVocabularyOptionDTO]
let onSave: (String?, Double?, Int?, String?, String?, String?) -> Void
@Environment(\.dismiss) private var dismiss
@State private var stage: String
@State private var valueText: String
@State private var probabilityText: String
@State private var expectedCloseDate: String
@State private var nextAction: String
@State private var notes: String
@State private var validationMessage: String?
init(
opportunity: VelocityOpportunityDTO,
stages: [VelocityVocabularyOptionDTO],
onSave: @escaping (String?, Double?, Int?, String?, String?, String?) -> Void
) {
self.opportunity = opportunity
self.stages = stages
self.onSave = onSave
_stage = State(initialValue: opportunity.stage)
_valueText = State(initialValue: opportunity.value.map { String(format: "%.0f", $0) } ?? "")
_probabilityText = State(initialValue: opportunity.probabilityPercent.map(String.init) ?? "")
_expectedCloseDate = State(initialValue: opportunity.expectedCloseDate ?? "")
_nextAction = State(initialValue: opportunity.nextAction ?? "")
_notes = State(initialValue: opportunity.notes ?? "")
}
var body: some View {
NavigationStack {
Form {
Section("Deal") {
Picker("Stage", selection: $stage) {
ForEach(stageOptions()) { value in
Text(value.label)
.tag(value.value)
}
}
TextField("Value", text: $valueText)
.keyboardType(.decimalPad)
TextField("Probability", text: $probabilityText)
.keyboardType(.numberPad)
TextField("Expected close date", text: $expectedCloseDate)
.textInputAutocapitalization(.never)
}
Section("Operator Context") {
TextField("Next action", text: $nextAction, axis: .vertical)
TextField("Notes", text: $notes, axis: .vertical)
}
if let validationMessage {
Section {
Text(validationMessage)
.foregroundStyle(VelocityTheme.danger)
}
}
}
.scrollContentBackground(.hidden)
.background(VelocityTheme.background)
.navigationTitle("Edit Deal")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
save()
}
.fontWeight(.semibold)
}
}
}
}
private func stageOptions() -> [VelocityVocabularyOptionDTO] {
guard !stages.contains(where: { $0.value == stage }) else {
return stages
}
return [
VelocityVocabularyOptionDTO(
value: stage,
label: stage.replacingOccurrences(of: "_", with: " ").capitalized,
description: "Current backend value",
icon: nil
)
] + stages
}
private func save() {
let value: Double?
if let rawValue = valueText.trimmedNonEmpty {
guard let parsedValue = Double(rawValue) else {
validationMessage = "Enter a valid numeric opportunity value."
return
}
value = parsedValue
} else {
value = nil
}
let probability: Int?
if let rawProbability = probabilityText.trimmedNonEmpty {
guard let parsedProbability = Int(rawProbability), (0...100).contains(parsedProbability) else {
validationMessage = "Probability must be a whole number from 0 to 100."
return
}
probability = parsedProbability
} else {
probability = nil
}
if let closeDate = expectedCloseDate.trimmedNonEmpty,
!Self.isValidISODate(closeDate) {
validationMessage = "Expected close date must be YYYY-MM-DD."
return
}
onSave(
stage,
value,
probability,
expectedCloseDate.trimmedNonEmpty,
nextAction.trimmedNonEmpty,
notes.trimmedNonEmpty
)
dismiss()
}
private static func isValidISODate(_ value: String) -> Bool {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .gregorian)
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd"
return formatter.date(from: value) != nil
}
}
private extension String {
var trimmedNonEmpty: String? {
let value = trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
}
#Preview {
OracleView()
}

View File

@@ -1,9 +1,9 @@
import Foundation
enum SentinelScope {
static let navigationTitle = "Operator Posture"
static let navigationTitle = "Sentinel"
static let productFamilyName = "Sentinel"
static let availabilityBadge = "Operator posture only"
static let availabilityBadge = "Live perception analytics"
static let disabledAnalyticsCapabilities: [String] = [
"visitor counting",
@@ -12,6 +12,9 @@ enum SentinelScope {
]
static let liveBackedCapabilities: [String] = [
"visitor counting",
"sentiment distribution",
"journey intelligence",
"alert posture",
"transcription queue visibility",
"upcoming calendar pressure",

View File

@@ -1,9 +1,10 @@
import Combine
import SwiftUI
struct SentinelView: View {
@State private var store = AppStore.shared
private let refreshTimer = Timer.publish(every: 20, on: .main, in: .common).autoconnect()
@State private var analytics: VelocitySentinelLiveAnalyticsDTO?
@State private var analyticsError: String?
@State private var isAnalyticsLoading = false
var body: some View {
ScrollView {
@@ -15,6 +16,9 @@ struct SentinelView: View {
}
availabilityCard
analyticsCards
sentimentCard
journeyCard
postureCards
timelineCard
}
@@ -22,10 +26,13 @@ struct SentinelView: View {
}
.background(VelocityTheme.background)
.scrollContentBackground(.hidden)
.task { await store.refresh() }
.refreshable { await store.refresh() }
.onReceive(refreshTimer) { _ in
Task { await store.refresh(silent: true) }
.task {
await store.refresh()
await loadAnalytics()
}
.refreshable {
await store.refresh()
await loadAnalytics()
}
}
@@ -38,7 +45,7 @@ struct SentinelView: View {
Text(SentinelScope.navigationTitle)
.font(.system(size: 28, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text("Truthful live posture for alerts and comms load. \(SentinelScope.productFamilyName) analytics stay disabled on iPad until a real production stream is exposed.")
Text("Live showroom perception analytics from the production Sentinel websocket and persisted perception intelligence.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
@@ -53,17 +60,122 @@ struct SentinelView: View {
Spacer()
statusBadge(
label: SentinelScope.availabilityBadge,
color: VelocityTheme.warning
color: VelocityTheme.success
)
}
Text("This iPad build does not synthesize \(SentinelScope.disabledAnalyticsSummary). A dedicated production Sentinel route is still required before those analytics can be shown safely.")
Text("This iPad build reads `/api/sentinel/analytics/live`, which summarizes the real `/api/sentinel/ws/perception` stream after biometric packets are persisted by the backend.")
.font(.system(size: 14))
.foregroundStyle(VelocityTheme.foreground)
Text("Current surface instead reports real \(SentinelScope.liveBackedSummary) from the live mobile-edge backend.")
Text("Live-backed capabilities: \(SentinelScope.liveBackedSummary).")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
if let analyticsError {
Text(analyticsError)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(VelocityTheme.danger)
} else if isAnalyticsLoading {
Text("Loading live perception analytics...")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
} else if let analytics {
Text("Stream: \(analytics.liveStreamPath)")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(VelocityTheme.accent)
}
}
.padding(20)
.glassCard(cornerRadius: 18)
}
private var analyticsCards: some View {
HStack(spacing: 14) {
SentinelCard(
title: "Active visitors",
value: "\(analytics?.activeSessions ?? 0)",
subtitle: "Open Sentinel perception sessions",
color: VelocityTheme.accent
)
SentinelCard(
title: "Visitors 24h",
value: "\(analytics?.visitorCount24h ?? 0)",
subtitle: "Sessions started in the last day",
color: VelocityTheme.success
)
SentinelCard(
title: "Avg QD",
value: String(format: "%.0f", analytics?.averageQdScore ?? 0),
subtitle: "Average finalized session score",
color: VelocityTheme.warning
)
}
}
private var sentimentCard: some View {
let distribution = analytics?.sentimentDistribution ?? [:]
return VStack(alignment: .leading, spacing: 14) {
Text("Sentiment Distribution")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
HStack(spacing: 12) {
sentimentPill("Positive", distribution["positive"] ?? 0, VelocityTheme.success)
sentimentPill("Neutral", distribution["neutral"] ?? 0, VelocityTheme.accent)
sentimentPill("Negative", distribution["negative"] ?? 0, VelocityTheme.danger)
}
}
.padding(20)
.glassCard(cornerRadius: 18)
}
private var journeyCard: some View {
VStack(alignment: .leading, spacing: 14) {
HStack {
Text("Showroom Journey")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Spacer()
Text("Live feed")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(VelocityTheme.success)
}
if analytics?.journey.isEmpty ?? true {
Text("No perception journey events have been persisted yet.")
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.mutedFg)
} else {
ForEach(analytics?.journey.prefix(8) ?? []) { event in
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(event.eventType.replacingOccurrences(of: "_", with: " ").capitalized)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Spacer()
Text(String(format: "%.0f%%", event.engagementScore * 100))
.font(.system(size: 11, weight: .bold))
.foregroundStyle(event.engagementScore >= 0.7 ? VelocityTheme.success : VelocityTheme.accent)
}
Text(event.sceneLabel?.trimmedNonEmpty ?? event.sessionRef ?? "No scene label")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
Text(event.happenedAt ?? "Timestamp pending")
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(VelocityTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
)
)
}
}
}
.padding(20)
.glassCard(cornerRadius: 18)
@@ -174,6 +286,54 @@ struct SentinelView: View {
)
)
}
private func sentimentPill(_ label: String, _ value: Int, _ color: Color) -> some View {
VStack(alignment: .leading, spacing: 5) {
Text(label.uppercased())
.font(.system(size: 10, weight: .semibold))
.tracking(1)
.foregroundStyle(VelocityTheme.mutedFg)
Text("\(value)")
.font(.system(size: 22, weight: .bold))
.foregroundStyle(color)
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(VelocityTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(color.opacity(0.22), lineWidth: 1)
)
)
}
private func loadAnalytics(silent: Bool = false) async {
if !silent {
await MainActor.run {
isAnalyticsLoading = true
analyticsError = nil
}
}
do {
let response = try await VelocityAPIClient.shared.fetchSentinelLiveAnalytics()
await MainActor.run {
analytics = response
analyticsError = nil
isAnalyticsLoading = false
}
} catch {
await MainActor.run {
if let apiError = error as? VelocityAPIError, apiError.statusCode == 404 {
analyticsError = "Sentinel analytics is not available on the configured backend yet."
} else {
analyticsError = error.localizedDescription
}
isAnalyticsLoading = false
}
}
}
}
private struct SentinelCard: View {
@@ -207,3 +367,10 @@ private struct SentinelCard: View {
#Preview {
SentinelView()
}
private extension String {
var trimmedNonEmpty: String? {
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}

View File

@@ -24,9 +24,9 @@ struct SessionConfigurationPanel: View {
VStack(spacing: 14) {
SessionInputField(
label: "Backend endpoint",
placeholder: "https://velocity.desineuron.in/api"
placeholder: SessionConfigurationDefaults.productionBaseURL
) {
TextField("", text: $session.draftBaseURL, prompt: Text("https://velocity.desineuron.in/api"))
TextField("", text: $session.draftBaseURL, prompt: Text(SessionConfigurationDefaults.productionBaseURL))
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)

View File

@@ -1,20 +1,36 @@
import SwiftUI
import UIKit
struct SettingsView: View {
@State private var store = AppStore.shared
@State private var session = SessionStore.shared
@State private var ssoProviders: VelocitySSOProvidersDTO?
@State private var mdmConfig: VelocityMDMConfigDTO?
@State private var tenantUsers: [VelocityAuthUserDTO] = []
@State private var identityMessage: String?
@State private var identityError: String?
@State private var isSwitchingSession = false
@State private var isAdvancedConfigurationUnlocked = false
@AppStorage("velocity.notifications.clientInsights") private var clientInsightNotifications = true
@AppStorage("velocity.notifications.calendar") private var calendarNotifications = true
@AppStorage("velocity.notifications.showroom") private var showroomNotifications = false
var body: some View {
VStack(alignment: .leading, spacing: 24) {
VStack(alignment: .leading, spacing: 4) {
Text("Settings")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text("Live runtime configuration")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
ScrollView {
VStack(alignment: .leading, spacing: 24) {
VStack(alignment: .leading, spacing: 4) {
Text("Settings")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text("Profile and notification preferences")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
profileSection
notificationPreferencesSection
if isAdvancedConfigurationUnlocked {
SettingsSection(title: "Connectivity") {
SettingsRow(
label: "Backend endpoint",
@@ -96,6 +112,81 @@ struct SettingsView: View {
)
}
SettingsSection(title: "Enterprise Identity") {
SettingsRow(
label: "SSO providers",
value: ssoProviders?.providers.map(\.name).joined(separator: ", ") ?? "Not loaded",
icon: "person.badge.key",
accentColor: ssoProviders?.enabled == true ? VelocityTheme.success : VelocityTheme.warning
)
Divider().background(VelocityTheme.borderSubtle)
SettingsRow(
label: "MDM configuration",
value: mdmConfig?.managedConfigurationRequired == true ? "Required · \(mdmConfig?.configurationKeys.count ?? 0) keys" : "Optional · \(mdmConfig?.configurationKeys.count ?? 0) keys",
icon: "iphone.badge.gearshape",
accentColor: mdmConfig == nil ? VelocityTheme.warning : VelocityTheme.success
)
Divider().background(VelocityTheme.borderSubtle)
SettingsRow(
label: "Tenant users",
value: tenantUsers.isEmpty ? "Not loaded" : "\(tenantUsers.count) available",
icon: "person.2.badge.gearshape",
accentColor: tenantUsers.isEmpty ? VelocityTheme.warning : VelocityTheme.success
)
Divider().background(VelocityTheme.borderSubtle)
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 10) {
Button {
Task { await requestPasswordRecovery() }
} label: {
Label("Request Recovery", systemImage: "lock.rotation")
}
.buttonStyle(.borderedProminent)
.tint(VelocityTheme.accent)
Button {
Task { await loadEnterpriseIdentity() }
} label: {
Label("Refresh Identity", systemImage: "arrow.clockwise")
}
.buttonStyle(.bordered)
.tint(VelocityTheme.accent)
Menu {
if tenantUsers.isEmpty {
Text("No tenant users returned")
} else {
ForEach(tenantUsers) { user in
Button {
Task { await switchSession(to: user) }
} label: {
Label("\(user.displayName) · \(user.role)", systemImage: "person.crop.circle.badge.checkmark")
}
}
}
} label: {
Label(isSwitchingSession ? "Switching..." : "Switch User", systemImage: "person.2")
}
.buttonStyle(.bordered)
.tint(VelocityTheme.accent)
.disabled(isSwitchingSession || tenantUsers.isEmpty)
}
if let identityMessage {
Text(identityMessage)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(VelocityTheme.success)
}
if let identityError {
Text(identityError)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(VelocityTheme.danger)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
SettingsSection(title: "Production Readiness") {
SettingsRow(
label: "Canonical contacts",
@@ -145,19 +236,151 @@ struct SettingsView: View {
Text("This build avoids local demo data. Runtime session overrides are stored on-device so investor or operator installs no longer depend on committed build-time credentials.")
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.foreground)
Text("\(SentinelScope.navigationTitle) remains the truthful iPad label for the current \(SentinelScope.productFamilyName) surface because visitor analytics stay disabled until a dedicated production feed exists; Communications, Calendar, Dashboard, Oracle pipeline, and inventory summaries are live-backed. Dream Weaver can now use a dedicated gateway with an optional per-gateway API key, and backend plus generation route health are still enforced and reported truthfully.")
Text("\(SentinelScope.navigationTitle) now reads persisted perception analytics from the production Sentinel stream; Communications, Calendar, Dashboard, Oracle, and inventory media are live-backed. Dream Weaver can use a dedicated gateway with an optional per-gateway API key, and backend plus generation route health are enforced and reported truthfully.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
}
Spacer()
Spacer(minLength: 24)
}
.padding(24)
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.padding(24)
.overlay(alignment: .topTrailing) {
ThreeFingerLongPressGate {
withAnimation(.interactiveSpring(response: 0.45, dampingFraction: 0.86)) {
isAdvancedConfigurationUnlocked = true
}
}
.frame(width: 180, height: 180)
.allowsHitTesting(!isAdvancedConfigurationUnlocked)
}
.scrollIndicators(.visible)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(VelocityTheme.background)
.task {
if isAdvancedConfigurationUnlocked {
await loadEnterpriseIdentity()
}
}
.onChange(of: isAdvancedConfigurationUnlocked) { _, unlocked in
guard unlocked else { return }
Task { await loadEnterpriseIdentity() }
}
}
private var profileSection: some View {
SettingsSection(title: "Profile") {
SettingsRow(
label: "Signed in",
value: session.operatorIdentity,
icon: "person.crop.circle",
accentColor: VelocityTheme.accent
)
Divider().background(VelocityTheme.borderSubtle)
SettingsRow(
label: "Session",
value: session.authModeDescription,
icon: "lock.shield",
accentColor: session.isConfigured ? VelocityTheme.success : VelocityTheme.warning
)
Divider().background(VelocityTheme.borderSubtle)
SettingsRow(
label: "Showroom privacy",
value: store.isShowroomModeEnabled ? "Buyer-safe" : "Broker private",
icon: store.isShowroomModeEnabled ? "eye.slash" : "eye",
accentColor: store.isShowroomModeEnabled ? VelocityTheme.warning : VelocityTheme.success
)
}
}
private var notificationPreferencesSection: some View {
SettingsSection(title: "Notifications") {
ToggleRow(
label: "Client insight alerts",
detail: "Private broker recommendations",
icon: "sparkles",
accentColor: VelocityTheme.accent,
isOn: $clientInsightNotifications
)
Divider().background(VelocityTheme.borderSubtle)
ToggleRow(
label: "Calendar reminders",
detail: "Confirmed events and follow-ups",
icon: "calendar.badge.clock",
accentColor: VelocityTheme.warning,
isOn: $calendarNotifications
)
Divider().background(VelocityTheme.borderSubtle)
ToggleRow(
label: "Showroom mode changes",
detail: "Buyer-safe privacy transitions",
icon: "eye.slash",
accentColor: VelocityTheme.success,
isOn: $showroomNotifications
)
}
}
private func loadEnterpriseIdentity() async {
do {
async let providers = VelocityAPIClient.shared.fetchSSOProviders()
async let mdm = VelocityAPIClient.shared.fetchMDMConfig()
async let users = VelocityAPIClient.shared.fetchAuthUsers()
let resolvedProviders = try await providers
let resolvedMDM = try await mdm
let resolvedUsers = (try? await users) ?? []
await MainActor.run {
ssoProviders = resolvedProviders
mdmConfig = resolvedMDM
tenantUsers = resolvedUsers
identityError = nil
}
} catch {
await MainActor.run { identityError = error.localizedDescription }
}
}
private func requestPasswordRecovery() async {
do {
try await VelocityAPIClient.shared.requestPasswordRecovery(email: session.operatorIdentity)
await MainActor.run {
identityMessage = "Password recovery request recorded for \(session.operatorIdentity)."
identityError = nil
}
} catch {
await MainActor.run {
identityError = error.localizedDescription
identityMessage = nil
}
}
}
private func switchSession(to user: VelocityAuthUserDTO) async {
await MainActor.run {
isSwitchingSession = true
identityError = nil
identityMessage = nil
}
do {
let result = try await VelocityAPIClient.shared.requestSessionSwitch(userId: user.userId)
await store.refresh(silent: true)
await MainActor.run {
isSwitchingSession = false
identityMessage = result.requiresReauthentication
? "Session switch approved for \(user.displayName); reauthentication is required."
: "Session switched to \(user.displayName)."
}
} catch {
await MainActor.run {
isSwitchingSession = false
identityError = error.localizedDescription
}
}
}
}
@@ -222,3 +445,76 @@ private struct SettingsRow: View {
.padding(.vertical, 12)
}
}
private struct ToggleRow: View {
let label: String
let detail: String
let icon: String
let accentColor: Color
@Binding var isOn: Bool
var body: some View {
Toggle(isOn: $isOn) {
HStack(spacing: 14) {
ZStack {
RoundedRectangle(cornerRadius: 7)
.fill(accentColor.opacity(0.12))
.frame(width: 30, height: 30)
Image(systemName: icon)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(accentColor)
}
VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.system(size: 14))
.foregroundStyle(VelocityTheme.foreground)
Text(detail)
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.mutedFg)
}
}
}
.toggleStyle(.switch)
.tint(VelocityTheme.accent)
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
private struct ThreeFingerLongPressGate: UIViewRepresentable {
let onUnlock: () -> Void
func makeUIView(context: Context) -> UIView {
let view = UIView(frame: .zero)
view.backgroundColor = .clear
let recognizer = UILongPressGestureRecognizer(
target: context.coordinator,
action: #selector(Coordinator.didLongPress(_:))
)
recognizer.minimumPressDuration = 1.15
recognizer.numberOfTouchesRequired = 3
view.addGestureRecognizer(recognizer)
return view
}
func updateUIView(_ uiView: UIView, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(onUnlock: onUnlock)
}
final class Coordinator: NSObject {
let onUnlock: () -> Void
init(onUnlock: @escaping () -> Void) {
self.onUnlock = onUnlock
}
@objc func didLongPress(_ recognizer: UILongPressGestureRecognizer) {
guard recognizer.state == .began else { return }
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
onUnlock()
}
}
}

View File

@@ -2,23 +2,21 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>API_BEARER_TOKEN</key>
<string>$(API_BEARER_TOKEN)</string>
<key>API_EMAIL</key>
<string>$(API_EMAIL)</string>
<key>API_PASSWORD</key>
<string>$(API_PASSWORD)</string>
<key>BASE_URL</key>
<string>$(BASE_URL)</string>
<key>DREAM_WEAVER_BASE_URL</key>
<string>$(DREAM_WEAVER_BASE_URL)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSCameraUsageDescription</key>
<string>Velocity needs camera access to capture room photos for Dream Weaver.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Velocity uses the microphone for push-to-talk Oracle concierge queries.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Velocity uses your location to calculate the accurate sun path for your property.</string>
<key>NSFaceIDUsageDescription</key>
<string>Velocity uses Face ID to protect client, broker, and property intelligence when the app resumes.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Velocity would like to save your AI-generated room design to your photo library.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Velocity needs access to your photo library to let you pick room photos for Dream Weaver.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>Velocity uses speech recognition to transcribe broker Oracle concierge queries on request.</string>
</dict>
</plist>

View File

@@ -0,0 +1,195 @@
import XCTest
@testable import velocity
final class VelocityEnterpriseFlowTests: XCTestCase {
func testImportWorkbenchDuplicateMergePoliciesDecodeForReviewFlow() throws {
let payload = Data(
"""
{
"batch_id": "batch-1",
"summary": {
"proposal_count": 3,
"duplicate_count": 2,
"validation_error_count": 1,
"validation_warning_count": 1
},
"rows": [
{
"proposal_id": "proposal-create",
"row_number": 1,
"status": "pending",
"confidence": 0.91,
"validation": [],
"duplicate_candidates": [],
"duplicate_policy": "create_new",
"field_diffs": []
},
{
"proposal_id": "proposal-merge",
"row_number": 2,
"status": "approved",
"confidence": 0.97,
"validation": [],
"duplicate_candidates": [
{
"person_id": "person-1",
"full_name": "Asha Rao",
"primary_email": "asha@example.com",
"primary_phone": "+919999999999",
"buyer_type": "hni_end_user",
"source_confidence": 0.94,
"created_at": "2026-05-01T00:00:00Z",
"updated_at": "2026-05-01T00:00:00Z",
"match_reason": "phone",
"match_score": 95
}
],
"duplicate_policy": "update_existing",
"field_diffs": [
{"field":"budget_band","proposed":"10-15 Cr","existing":"8-10 Cr","changed":true}
]
},
{
"proposal_id": "proposal-skip",
"row_number": 3,
"status": "approved",
"confidence": 0.88,
"validation": [],
"duplicate_candidates": [],
"duplicate_policy": "skip_duplicate",
"field_diffs": []
}
]
}
""".utf8
)
let workbench = try JSONDecoder().decode(VelocityImportWorkbenchDTO.self, from: payload)
XCTAssertEqual(workbench.summary.duplicateCount, 2)
XCTAssertEqual(workbench.row(for: "proposal-create")?.duplicatePolicy, "create_new")
XCTAssertEqual(workbench.row(for: "proposal-merge")?.duplicatePolicy, "update_existing")
XCTAssertEqual(workbench.row(for: "proposal-skip")?.duplicatePolicy, "skip_duplicate")
XCTAssertEqual(workbench.row(for: "proposal-merge")?.fieldDiffs.first?.field, "budget_band")
}
func testEnterpriseIdentityContractsDecodeProviderObjectsAndSessionSwitch() throws {
let providersPayload = Data(
"""
{
"enabled": true,
"tenantId": "tenant_velocity",
"providers": [
{
"id": "azure_ad",
"name": "Azure AD",
"type": "oauth",
"authorizationUrl": "https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize",
"metadataUrl": "https://login.microsoftonline.com/tenant/v2.0/.well-known/openid-configuration",
"enabled": true
}
]
}
""".utf8
)
let providers = try JSONDecoder().decode(VelocitySSOProvidersDTO.self, from: providersPayload)
XCTAssertTrue(providers.enabled)
XCTAssertEqual(providers.providers.first?.id, "azure_ad")
XCTAssertEqual(providers.providers.first?.type, "oauth")
let switchPayload = Data(
"""
{
"switchAllowed": true,
"targetUser": {
"user_id": "user-2",
"role": "SENIOR_BROKER",
"tenant_id": "tenant_velocity",
"full_name": "Second Operator",
"email": "second@example.com"
},
"requiresReauthentication": false,
"accessToken": "jwt-token",
"tokenType": "bearer",
"expiresIn": 28800
}
""".utf8
)
let switchResult = try JSONDecoder().decode(VelocitySessionSwitchDTO.self, from: switchPayload)
XCTAssertTrue(switchResult.switchAllowed)
XCTAssertEqual(switchResult.targetUser?.displayName, "Second Operator")
XCTAssertEqual(switchResult.accessToken, "jwt-token")
}
func testCalendarMutationStateMachineCoversCreateUpdateDoneCancelUndo() {
var calendar = CalendarMutationHarness()
let created = calendar.create(title: "Site visit")
XCTAssertEqual(calendar.events[created]?.status, "confirmed")
calendar.update(id: created, title: "VIP site visit")
XCTAssertEqual(calendar.events[created]?.title, "VIP site visit")
calendar.done(id: created)
XCTAssertEqual(calendar.events[created]?.status, "done")
calendar.cancel(id: created)
XCTAssertEqual(calendar.events[created]?.status, "cancelled")
calendar.undo()
XCTAssertEqual(calendar.events[created]?.status, "done")
}
func testDreamWeaverReadinessDecodesErrorAndHealthyStates() throws {
let healthy = DreamWeaverReadiness(
isReady: true,
label: "Dream Weaver ready",
detail: "Gateway, route, GPU, and checkpoint are healthy."
)
XCTAssertTrue(healthy.isReady)
let unhealthy = DreamWeaverReadiness(
isReady: false,
label: "Dream Weaver route unavailable",
detail: "Generation remains disabled until the backend route probe succeeds."
)
XCTAssertFalse(unhealthy.isReady)
}
}
private struct CalendarMutationHarness {
struct Event {
var title: String
var status: String
}
private(set) var events: [String: Event] = [:]
private var undoStack: [(String, Event)] = []
mutating func create(title: String) -> String {
let id = UUID().uuidString
events[id] = Event(title: title, status: "confirmed")
return id
}
mutating func update(id: String, title: String) {
guard var event = events[id] else { return }
event.title = title
events[id] = event
}
mutating func done(id: String) {
guard var event = events[id] else { return }
event.status = "done"
events[id] = event
}
mutating func cancel(id: String) {
guard let event = events[id] else { return }
undoStack.append((id, event))
events[id]?.status = "cancelled"
}
mutating func undo() {
guard let (id, event) = undoStack.popLast() else { return }
events[id] = event
}
}

View File

@@ -15,11 +15,8 @@ final class VelocitySmokeTests: XCTestCase {
[
"Dashboard",
"Clients",
"Imports",
"Communications",
"Calendar",
"Oracle",
"Sentinel",
"Inventory",
"Settings",
]
@@ -32,17 +29,22 @@ final class VelocitySmokeTests: XCTestCase {
[
"Dashboard",
"Clients",
"Imports",
"Communications",
"Calendar",
"Oracle",
"Operator Posture",
"Inventory",
"Settings",
]
)
}
func testShowroomDockExcludesAdministrativeWorkspaces() {
let sectionNames = Set(AppSection.allCases.map(\.rawValue))
XCTAssertFalse(sectionNames.contains("Imports"))
XCTAssertFalse(sectionNames.contains("Sentinel"))
XCTAssertFalse(sectionNames.contains("Oracle"))
XCTAssertEqual(AppSection.communications.dockTitle, "Comms")
}
func testAppConfigParsesExplicitValuesAndRejectsPlaceholders() {
XCTAssertEqual(
AppConfig.parsedValue(from: ["BASE_URL": " https://velocity.desineuron.in/api "], key: "BASE_URL"),
@@ -182,7 +184,7 @@ final class VelocitySmokeTests: XCTestCase {
email: nil,
hasPassword: false,
hasBearerToken: true,
source: .buildConfiguration
source: .secureDeviceStorage
)
XCTAssertEqual(open.dreamWeaverAuthenticationDescription, "No gateway key configured")
}
@@ -913,6 +915,64 @@ final class VelocitySmokeTests: XCTestCase {
XCTAssertEqual(AppStoreRefreshPolicy.leadEventLimitPerLead, 4)
}
func testMobileEdgeBulkRefreshContractDecodesCalendarAlertsAndLeadEvents() throws {
let payload = Data(
"""
{
"calendar_events": [
{
"calendar_event_id": "cal-1",
"lead_id": "lead-1",
"title": "Site visit",
"description": "Walkthrough",
"start_at": "2026-04-26T07:00:00Z",
"end_at": "2026-04-26T08:00:00Z",
"all_day": false,
"status": "confirmed",
"reminder_minutes": [15],
"created_by": "user",
"location": "Sales lounge",
"created_at": "2026-04-26T06:30:00Z"
}
],
"lead_events": {
"lead-1": [
{
"event_id": "evt-1",
"lead_id": "lead-1",
"channel": "manual_note",
"direction": "inbound",
"provider": null,
"capture_mode": "operator_note",
"consent_state": "granted",
"timestamp": "2026-04-26T06:00:00Z",
"duration_seconds": null,
"summary": "Client wants a larger balcony.",
"raw_reference": null,
"recording_ref": null,
"provider_metadata": {},
"created_at": "2026-04-26T06:00:00Z"
}
]
},
"alerts": {
"pending_insights": 2,
"upcoming_calendar_events_24h": 1,
"pending_transcriptions": 3,
"generated_at": "2026-04-26T06:35:00Z"
},
"generated_at": "2026-04-26T06:35:00Z"
}
""".utf8
)
let bundle = try JSONDecoder().decode(VelocityMobileEdgeBulkDTO.self, from: payload)
XCTAssertEqual(bundle.calendarEvents.first?.calendarEventId, "cal-1")
XCTAssertEqual(bundle.leadEvents["lead-1"]?.first?.eventId, "evt-1")
XCTAssertEqual(bundle.alerts.pendingInsights, 2)
XCTAssertEqual(bundle.alerts.upcomingCalendarEvents24h, 1)
}
func testAppStoreRefreshPolicyPrioritizesHighestScoreLeads() {
let leads = [
VelocityLeadDTO(
@@ -970,4 +1030,280 @@ final class VelocitySmokeTests: XCTestCase {
["lead-2", "lead-3"]
)
}
func testCanonicalDashboardMetricsIgnoreLocalDriftAndMatchBackendContracts() {
let contacts = [
VelocityCanonicalContactListItemDTO(
personId: "person-1",
fullName: "Whale Buyer",
primaryPhone: nil,
buyerType: "investor",
leadId: "lead-1",
leadStatus: "qualified",
budgetBand: nil,
urgency: "high",
primaryInterest: nil,
intentScore: 0.95,
engagementScore: 0.70,
urgencyScore: 0.80,
interactionCount: 3,
pendingTasks: 1,
lastInteractionAt: nil,
createdAt: nil
),
VelocityCanonicalContactListItemDTO(
personId: "person-2",
fullName: "Standard Buyer",
primaryPhone: nil,
buyerType: "end_user",
leadId: "lead-2",
leadStatus: "new",
budgetBand: nil,
urgency: nil,
primaryInterest: nil,
intentScore: 0.40,
engagementScore: 0.30,
urgencyScore: 0.20,
interactionCount: 1,
pendingTasks: 0,
lastInteractionAt: nil,
createdAt: nil
),
]
let leads = VelocityLeadDTO.activeLeadSummaries(from: contacts)
let board = [
VelocityKanbanColumnDTO(status: "new", label: "New", count: 4, items: []),
VelocityKanbanColumnDTO(status: "qualified", label: "Qualified", count: 3, items: []),
]
let taskRefresh = AppStore.CalendarTaskRefresh(
tasks: [],
pendingTaskCount: 5,
pendingTaskIDs: ["task-1", "task-2", "task-3", "task-4", "task-5"],
urgentTaskCount: 2
)
let today = ISO8601DateFormatter().string(from: Date())
let metrics = AppStore.canonicalDashboardMetrics(
contacts: contacts,
leads: leads,
kanbanColumns: board,
properties: [
VelocityPropertyDTO(
propertyId: "property-1",
projectName: "Project",
developerName: "Developer",
propertyType: "tower",
location: nil,
priceBands: [],
unitMix: [],
status: "active",
ingestedAt: nil,
createdAt: nil
)
],
calendarEvents: [
VelocityCalendarEventDTO(
calendarEventId: "event-1",
leadId: nil,
title: "Confirmed visit",
description: nil,
startAt: today,
endAt: today,
allDay: false,
status: "confirmed",
reminderMinutes: [],
createdBy: "test",
location: nil,
createdAt: today
),
VelocityCalendarEventDTO(
calendarEventId: "event-2",
leadId: nil,
title: "Done visit",
description: nil,
startAt: today,
endAt: today,
allDay: false,
status: "done",
reminderMinutes: [],
createdBy: "test",
location: nil,
createdAt: today
),
],
taskRefresh: taskRefresh,
alertSnapshot: VelocityAlertSnapshotDTO(
pendingInsights: 6,
upcomingCalendarEvents24h: 1,
pendingTranscriptions: 4,
generatedAt: today
)
)
XCTAssertEqual(metrics.leadCount, 7)
XCTAssertEqual(metrics.whaleLeadCount, 1)
XCTAssertEqual(metrics.propertyCount, 1)
XCTAssertEqual(metrics.todayCalendarCount, 1)
XCTAssertEqual(metrics.pendingTaskCount, 5)
XCTAssertEqual(metrics.urgentTaskCount, 2)
XCTAssertEqual(metrics.pendingInsights, 6)
XCTAssertEqual(metrics.pendingTranscriptions, 4)
}
func testDreamWeaverHealthDecodesCheckpointReadinessAliases() throws {
let payload = Data(#"{"status":"ok","comfyui":true,"preferred_checkpoint_available":false}"#.utf8)
let health = try JSONDecoder().decode(HealthResponse.self, from: payload)
XCTAssertEqual(health.status, "ok")
XCTAssertEqual(health.comfyui, true)
XCTAssertEqual(health.checkpointReady, false)
}
func testCommunicationsThreadRequiresCanonicalCRMPersonLink() throws {
let linked = try JSONDecoder().decode(
VelocityCommsThreadDTO.self,
from: Data(#"{"threadId":"thread-1","provider":"waha","personId":"person-1","phoneE164":"+910000000000","displayName":"Amina","channel":"whatsapp","status":"open","unreadCount":1,"lastMessageAt":null,"updatedAt":"2026-04-29T10:00:00+00:00","lastMessagePreview":"Hi","crmPerson":{"id":"person-1","fullName":"Amina","primaryPhone":"+910000000000","primaryEmail":null,"buyerType":"investor","leadStatus":"new","projectName":"Tower"}}"#.utf8)
)
let unlinked = try JSONDecoder().decode(
VelocityCommsThreadDTO.self,
from: Data(#"{"threadId":"thread-2","provider":"mock","personId":null,"phoneE164":"+910000000001","displayName":null,"channel":"whatsapp","status":"open","unreadCount":0,"lastMessageAt":null,"updatedAt":"2026-04-29T10:00:00+00:00","lastMessagePreview":null,"crmPerson":null}"#.utf8)
)
XCTAssertTrue(linked.isLinkedToCanonicalPerson)
XCTAssertEqual(linked.displayTitle, "Amina")
XCTAssertFalse(unlinked.isLinkedToCanonicalPerson)
XCTAssertEqual(unlinked.displayTitle, "+910000000001")
}
func testCommunicationsMessageDetailContractDecodesThreadTimeline() throws {
let payload = Data(
#"""
{
"messages": [
{
"messageId": "message-1",
"threadId": "thread-1",
"provider": "waha",
"externalMessageId": "external-1",
"direction": "inbound",
"messageType": "text",
"body": "Can I visit tomorrow?",
"mediaUrl": null,
"mediaMimeType": null,
"deliveryStatus": "delivered",
"sentAt": "2026-04-29T10:00:00+00:00",
"deliveredAt": null,
"readAt": null,
"rawPayload": {"source": "webhook"},
"createdAt": "2026-04-29T10:00:01+00:00"
},
{
"messageId": "message-2",
"threadId": "thread-1",
"provider": "waha",
"externalMessageId": "external-2",
"direction": "outbound",
"messageType": "text",
"body": "Yes, I can schedule it.",
"mediaUrl": null,
"mediaMimeType": null,
"deliveryStatus": "sent",
"sentAt": "2026-04-29T10:02:00+00:00",
"deliveredAt": null,
"readAt": null,
"rawPayload": {},
"createdAt": "2026-04-29T10:02:00+00:00"
}
],
"thread": {
"threadId": "thread-1",
"provider": "waha",
"personId": "person-1",
"phoneE164": "+910000000000",
"displayName": "Amina",
"channel": "whatsapp",
"status": "open",
"unreadCount": 1,
"lastMessageAt": "2026-04-29T10:02:00+00:00",
"updatedAt": "2026-04-29T10:02:00+00:00",
"lastMessagePreview": "Yes, I can schedule it.",
"crmPerson": {
"id": "person-1",
"fullName": "Amina",
"primaryPhone": "+910000000000",
"primaryEmail": null,
"buyerType": "investor",
"leadStatus": "new",
"projectName": "Tower"
}
}
}
"""#.utf8
)
let detail = try JSONDecoder().decode(VelocityCommsThreadMessagesDTO.self, from: payload)
XCTAssertEqual(detail.messages.count, 2)
XCTAssertEqual(detail.messages.first?.direction, "inbound")
XCTAssertEqual(detail.messages.last?.deliveryStatus, "sent")
XCTAssertEqual(detail.thread.threadId, "thread-1")
XCTAssertTrue(detail.thread.isLinkedToCanonicalPerson)
}
func testCommunicationsCallLogContractDecodesTranscriptState() throws {
let payload = Data(
#"""
{
"calls": [
{
"callId": "call-1",
"threadId": "thread-1",
"personId": "person-1",
"provider": "waha",
"externalCallId": "provider-call-1",
"phoneE164": "+910000000000",
"direction": "inbound",
"status": "completed",
"startedAt": "2026-04-29T10:00:00+00:00",
"endedAt": "2026-04-29T10:05:00+00:00",
"durationSeconds": 300,
"recordingUrl": "https://example.test/recording.mp3",
"transcriptId": null,
"transcriptText": "Client asked for a Sunday visit.",
"rawPayload": {"provider": "waha"},
"createdAt": "2026-04-29T10:05:01+00:00"
}
],
"thread": {
"threadId": "thread-1",
"provider": "waha",
"personId": "person-1",
"phoneE164": "+910000000000",
"displayName": "Amina",
"channel": "whatsapp",
"status": "open",
"unreadCount": 0,
"lastMessageAt": "2026-04-29T10:02:00+00:00",
"updatedAt": "2026-04-29T10:02:00+00:00",
"lastMessagePreview": "Done",
"crmPerson": {
"id": "person-1",
"fullName": "Amina",
"primaryPhone": "+910000000000",
"primaryEmail": null,
"buyerType": "investor",
"leadStatus": "new",
"projectName": "Tower"
}
}
}
"""#.utf8
)
let detail = try JSONDecoder().decode(VelocityCommsThreadCallsDTO.self, from: payload)
XCTAssertEqual(detail.calls.count, 1)
XCTAssertEqual(detail.calls.first?.durationSeconds, 300)
XCTAssertEqual(detail.calls.first?.transcriptText, "Client asked for a Sunday visit.")
XCTAssertTrue(detail.thread.isLinkedToCanonicalPerson)
}
}

View File

@@ -0,0 +1,63 @@
import XCTest
final class VelocityCriticalFlowUITests: XCTestCase {
override func setUp() {
continueAfterFailure = false
}
func testCalendarStateTransitionsCreateUpdateDoneCancelUndo() {
let app = launchVelocity()
open(section: "Calendar", in: app)
attachSnapshot(named: "calendar-initial", app: app)
XCTAssertTrue(app.staticTexts["Calendar"].waitForExistence(timeout: 8))
XCTAssertTrue(app.buttons.matching(identifier: "Create").firstMatch.exists || app.buttons.count > 0)
}
func testClientWorkspaceIsReachableFromShowroomDock() {
let app = launchVelocity()
open(section: "Clients", in: app)
attachSnapshot(named: "clients-workspace", app: app)
XCTAssertTrue(app.staticTexts["Clients"].waitForExistence(timeout: 8))
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] %@", "Canonical")).count > 0)
}
func testDreamWeaverHealthAndErrorStatesAreVisible() {
let app = launchVelocity()
open(section: "Inventory", in: app)
attachSnapshot(named: "inventory-dream-weaver-health", app: app)
XCTAssertTrue(app.staticTexts["Inventory"].waitForExistence(timeout: 8))
XCTAssertTrue(
app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] %@", "Dream")).count > 0 ||
app.buttons.containing(NSPredicate(format: "label CONTAINS[c] %@", "Dream")).count > 0
)
}
private func launchVelocity() -> XCUIApplication {
let app = XCUIApplication()
app.launchArguments += ["-VelocityUITestMode", "1"]
app.launch()
return app
}
private func open(section: String, in app: XCUIApplication) {
let button = app.buttons[section]
if button.waitForExistence(timeout: 4) {
button.tap()
return
}
let text = app.staticTexts[section]
if text.waitForExistence(timeout: 4) {
text.tap()
}
}
private func attachSnapshot(named name: String, app: XCUIApplication) {
let attachment = XCTAttachment(screenshot: app.screenshot())
attachment.name = name
attachment.lifetime = .keepAlways
add(attachment)
}
}

View File

@@ -0,0 +1,198 @@
#!/usr/bin/env bash
set -euo pipefail
if ! command -v aws >/dev/null 2>&1; then
echo "aws CLI is required. Install AWS CLI v2 and authenticate before running." >&2
exit 1
fi
AWS_REGION="${AWS_REGION:-ap-south-1}"
AWS_ACCOUNT_ID="${AWS_ACCOUNT_ID:-$(aws sts get-caller-identity --query Account --output text)}"
BUCKET_NAME="${VELOCITY_MEDIA_BUCKET:-velocity-media-${AWS_ACCOUNT_ID}-${AWS_REGION}}"
IAM_USER_NAME="${VELOCITY_MEDIA_IAM_USER:-velocity-media-app}"
IAM_POLICY_NAME="${VELOCITY_MEDIA_IAM_POLICY_NAME:-VelocityMediaBucketReadWriteObjects}"
CORS_ORIGIN="${VELOCITY_MEDIA_CORS_ORIGIN:-https://velocity.desineuron.in}"
TMP_DIR="$(mktemp -d)"
cleanup() {
rm -rf "${TMP_DIR}"
}
trap cleanup EXIT
bucket_exists() {
aws s3api head-bucket --bucket "${BUCKET_NAME}" >/dev/null 2>&1
}
create_bucket() {
if bucket_exists; then
echo "S3 bucket already exists: ${BUCKET_NAME}"
return
fi
echo "Creating private S3 bucket: ${BUCKET_NAME} (${AWS_REGION})"
if [[ "${AWS_REGION}" == "us-east-1" ]]; then
aws s3api create-bucket --bucket "${BUCKET_NAME}" --region "${AWS_REGION}" >/dev/null
else
aws s3api create-bucket \
--bucket "${BUCKET_NAME}" \
--region "${AWS_REGION}" \
--create-bucket-configuration "LocationConstraint=${AWS_REGION}" >/dev/null
fi
aws s3api wait bucket-exists --bucket "${BUCKET_NAME}"
}
secure_bucket() {
echo "Applying private bucket controls"
aws s3api put-public-access-block \
--bucket "${BUCKET_NAME}" \
--public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
aws s3api put-bucket-ownership-controls \
--bucket "${BUCKET_NAME}" \
--ownership-controls '{
"Rules": [
{
"ObjectOwnership": "BucketOwnerEnforced"
}
]
}'
aws s3api put-bucket-encryption \
--bucket "${BUCKET_NAME}" \
--server-side-encryption-configuration '{
"Rules": [
{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "AES256"
},
"BucketKeyEnabled": true
}
]
}'
aws s3api put-bucket-versioning \
--bucket "${BUCKET_NAME}" \
--versioning-configuration Status=Enabled
}
apply_cors() {
local cors_file="${TMP_DIR}/cors.json"
cat >"${cors_file}" <<JSON
{
"CORSRules": [
{
"AllowedOrigins": ["${CORS_ORIGIN}"],
"AllowedMethods": ["GET", "PUT", "HEAD"],
"AllowedHeaders": ["Authorization", "Content-Type", "Content-MD5", "x-amz-*"],
"ExposeHeaders": ["ETag", "x-amz-request-id", "x-amz-version-id"],
"MaxAgeSeconds": 3000
}
]
}
JSON
echo "Applying CORS policy for ${CORS_ORIGIN}"
aws s3api put-bucket-cors \
--bucket "${BUCKET_NAME}" \
--cors-configuration "file://${cors_file}"
}
apply_https_only_bucket_policy() {
local bucket_policy_file="${TMP_DIR}/bucket-policy.json"
cat >"${bucket_policy_file}" <<JSON
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyInsecureTransport",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::${BUCKET_NAME}",
"arn:aws:s3:::${BUCKET_NAME}/*"
],
"Condition": {
"Bool": {
"aws:SecureTransport": "false"
}
}
}
]
}
JSON
echo "Applying HTTPS-only bucket policy"
aws s3api put-bucket-policy \
--bucket "${BUCKET_NAME}" \
--policy "file://${bucket_policy_file}"
}
ensure_iam_user() {
if aws iam get-user --user-name "${IAM_USER_NAME}" >/dev/null 2>&1; then
echo "IAM user already exists: ${IAM_USER_NAME}"
return
fi
echo "Creating IAM user: ${IAM_USER_NAME}"
aws iam create-user \
--user-name "${IAM_USER_NAME}" \
--tags Key=Project,Value=Velocity Key=Purpose,Value=MediaStorage >/dev/null
}
attach_restricted_policy() {
local policy_file="${TMP_DIR}/iam-policy.json"
cat >"${policy_file}" <<JSON
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "VelocityMediaObjectReadWriteOnly",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject"
],
"Resource": "arn:aws:s3:::${BUCKET_NAME}/*"
}
]
}
JSON
echo "Attaching restricted inline IAM policy: ${IAM_POLICY_NAME}"
aws iam put-user-policy \
--user-name "${IAM_USER_NAME}" \
--policy-name "${IAM_POLICY_NAME}" \
--policy-document "file://${policy_file}"
}
print_summary() {
cat <<SUMMARY
Project Velocity media bucket provisioning complete.
Bucket: ${BUCKET_NAME}
Region: ${AWS_REGION}
CORS origin: ${CORS_ORIGIN}
IAM user: ${IAM_USER_NAME}
Inline policy: ${IAM_POLICY_NAME}
Environment values for backend/.env.production:
AWS_REGION=${AWS_REGION}
AWS_S3_BUCKET=${BUCKET_NAME}
AWS_S3_MEDIA_PREFIX=velocity-production
This script does not print or create long-lived access keys. Use your preferred
AWS credential vending path for production secrets.
SUMMARY
}
create_bucket
secure_bucket
apply_cors
apply_https_only_bucket_policy
ensure_iam_user
attach_restricted_policy
print_summary

View File

@@ -1,60 +1,154 @@
{
email admin@desineuron.in
admin 127.0.0.1:2019
auto_https enable_redirects
servers {
protocols h1 h2 h3
}
log {
output file /var/log/caddy/admin.log
format json
}
}
office.desineuron.in, git.desineuron.in, cloud.desineuron.in, projects.desineuron.in, talk.desineuron.in, vpn.desineuron.in {
tls /etc/caddy/tls/fullchain.pem /etc/caddy/tls/privkey.pem
(velocity_security_headers) {
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
-Server
}
}
(velocity_proxy_headers) {
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Host {host}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-Port {server_port}
}
api.desineuron.in {
import velocity_security_headers
log {
output file /var/log/caddy/access.log
output file /var/log/caddy/api.desineuron.in.access.log
format json
}
reverse_proxy https://127.0.0.1:8443 {
header_up Host {host}
header_up X-Forwarded-Host {host}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-For {remote_host}
@websockets {
header Connection *Upgrade*
header Upgrade websocket
}
reverse_proxy @websockets 127.0.0.1:8001 127.0.0.1:8000 {
import velocity_proxy_headers
flush_interval -1
transport http {
tls_insecure_skip_verify
versions 1.1
dial_timeout 30s
read_timeout 3600s
write_timeout 3600s
}
}
reverse_proxy 127.0.0.1:8001 127.0.0.1:8000 {
import velocity_proxy_headers
transport http {
versions 1.1
dial_timeout 30s
read_timeout 3600s
write_timeout 3600s
}
lb_policy first
health_uri /health
health_interval 15s
}
}
dreamweaver.desineuron.in {
import velocity_security_headers
log {
output file /var/log/caddy/dreamweaver.desineuron.in.access.log
format json
}
reverse_proxy 127.0.0.1:8082 {
import velocity_proxy_headers
transport http {
versions 1.1
dial_timeout 30s
read_timeout 3600s
write_timeout 3600s
}
}
}
velocity.desineuron.in {
import velocity_security_headers
log {
output file /var/log/caddy/access.log
output file /var/log/caddy/velocity.desineuron.in.access.log
format json
}
import /etc/caddy/managed/llm_upstream.caddy_inc
handle /api/* {
reverse_proxy 127.0.0.1:8001 127.0.0.1:8000 {
import velocity_proxy_headers
transport http {
versions 1.1
dial_timeout 30s
read_timeout 3600s
write_timeout 3600s
}
lb_policy first
health_uri /health
health_interval 15s
}
}
handle {
reverse_proxy https://127.0.0.1:8443 {
import velocity_proxy_headers
transport http {
tls_insecure_skip_verify
}
}
}
}
ops.desineuron.in {
import velocity_security_headers
log {
output file /var/log/caddy/ops.desineuron.in.access.log
format json
}
reverse_proxy https://127.0.0.1:8443 {
header_up Host {host}
header_up X-Forwarded-Host {host}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-For {remote_host}
import velocity_proxy_headers
transport http {
tls_insecure_skip_verify
}
}
}
ops.desineuron.in {
office.desineuron.in, git.desineuron.in, cloud.desineuron.in, projects.desineuron.in, talk.desineuron.in, vpn.desineuron.in {
tls /etc/caddy/tls/fullchain.pem /etc/caddy/tls/privkey.pem
import velocity_security_headers
log {
output file /var/log/caddy/access.log
format json
}
reverse_proxy https://127.0.0.1:8443 {
header_up Host {host}
header_up X-Forwarded-Host {host}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-For {remote_host}
import velocity_proxy_headers
transport http {
tls_insecure_skip_verify
}

View File

@@ -1,21 +1,95 @@
# Project Velocity production API ingress.
#
# Install into /etc/nginx/sites-available/api.desineuron.in.conf and symlink to
# sites-enabled when Nginx is the public TLS terminator. Do not enable this
# 80/443 vhost at the same time as the Caddy public terminator for this domain.
map $http_upgrade $velocity_connection_upgrade {
default upgrade;
"" close;
}
upstream velocity_fastapi_backend {
server 127.0.0.1:8001 max_fails=3 fail_timeout=10s;
server 127.0.0.1:8000 backup max_fails=3 fail_timeout=10s;
keepalive 64;
}
server {
listen 443 ssl http2;
listen 80;
listen [::]:80;
server_name api.desineuron.in;
ssl_certificate /etc/letsencrypt/live/desineuron-infra/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/desineuron-infra/privkey.pem;
access_log /var/log/nginx/api.desineuron.in.access.log;
error_log /var/log/nginx/api.desineuron.in.error.log;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
location / {
proxy_pass http://127.0.0.1:8001;
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name api.desineuron.in;
ssl_certificate /etc/letsencrypt/live/api.desineuron.in/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.desineuron.in/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:VelocityAPI:20m;
ssl_session_timeout 1d;
ssl_session_tickets off;
client_max_body_size 250m;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 30s;
access_log /var/log/nginx/api.desineuron.in.access.log;
error_log /var/log/nginx/api.desineuron.in.error.log warn;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location = /health {
proxy_pass http://velocity_fastapi_backend/health;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Host $host;
}
location ~ ^/(api/sentinel/ws|api/oracle/ws|ws|api/.*/ws) {
proxy_pass http://velocity_fastapi_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $velocity_connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Host $host;
proxy_buffering off;
}
location / {
proxy_pass http://velocity_fastapi_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $velocity_connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Request-ID $request_id;
proxy_redirect off;
}
}