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