feat: Ipad app features and Dream Weaver for Velocity WebOS
Some checks failed
Production Readiness / backend-contracts (pull_request) Has been cancelled
Production Readiness / webos-typecheck (pull_request) Has been cancelled
Production Readiness / ipad-parse (pull_request) Has been cancelled

This commit is contained in:
Sayan Datta
2026-04-28 10:59:07 +05:30
parent 184bfa77f8
commit fefe8373ec
117 changed files with 19510 additions and 6383 deletions

View File

@@ -0,0 +1,565 @@
# Codebase Analysis v1.1.md
## Table of Contents / Chapters
### 1. Overview
- Introduction to Project Velocity and its purpose
- Core principles and approach
### 2. Architectural Mapping
- Overall System Architecture (Mermaid diagram)
- File Dependency Graph (Mermaid diagram)
- Data Flow Architecture (Mermaid diagram)
### 3. Logic Decomposition
- Authentication & Authorization
- CRM Data Model
- Sentinel Biometric Intelligence
- Oracle Natural Language Intelligence
- Catalyst Marketing Orchestration
- Infrastructure & Deployment
### 4. Connectivity Matrix
- Component interconnections and data flow
- Interconnection rationale
### 5. First-Principles Guide
- Core Concept: AI-Augmented Sales Intelligence
- Why Real Estate Specifically?
- Principle 1: Data Sovereignty First
- Principle 2: Real-Time Perception Matters
- Principle 3: Intelligence Through Conversation
- Principle 4: Visual Storytelling Drives Sales
- Principle 5: Revision Control for Business Logic
- Design Philosophy: Production-Ready Craft
- Why This Architecture Succeeds
### 6. API Endpoints Reference
- Authentication Endpoints
- CRM Endpoints
- Analytics Endpoints
- Oracle AI Intelligence Endpoints
- Oracle Canvas Management (v1)
- Oracle Template Management
- Sentinel Biometric Intelligence Endpoints
- Catalyst Marketing Orchestration Endpoints
- Vault Trackable Links Endpoints
- CCTV Surveillance Integration Endpoints
- Video Scene Mapping Endpoints
- Marketing Videos Endpoints
- Mobile Edge Communication Endpoints
- Inventory Management Endpoints
- Admin Surface Control Endpoints
- CRM Canonical Data Endpoints
- Runtime LLM Endpoints
- Infrastructure Notes
## Overview
Project Velocity is an on-prem real estate operating system designed for high-value property sales. It combines a premium WebOS, an iPad field app, a FastAPI neural core, ComfyUI-based media generation, and biometric/sentiment-assisted sales intelligence. The system enables brokers to operate at the speed of AI while preserving control, provenance, and safety for customer and revenue-critical data.
This analysis provides a comprehensive understanding of the codebase from first principles, applying the Feynman Technique to distill complex implementations into intuitive concepts.
## Architectural Mapping
### Overall System Architecture
```mermaid
graph TB
subgraph "User Interfaces"
WebOS[Velocity WebOS<br/>React + TypeScript]
iPad[iPad App<br/>Swift + MediaPipe]
end
subgraph "Core Backend"
FastAPI[FastAPI Neural Core<br/>PostgreSQL + JWT Auth]
end
subgraph "AI Services"
Oracle[The Oracle<br/>Natural Language Intelligence]
Sentinel[The Sentinel<br/>Biometric Perception Engine]
Catalyst[The Catalyst<br/>Marketing Campaign Orchestration]
Comfy[ComfyUI / Dream Weaver<br/>Media Generation]
end
subgraph "Infrastructure"
AWS[AWS GPU Workers<br/>NVIDIA GPUs]
S3[S3 Asset Store<br/>Models + Media]
Linux[Linux Control Surface<br/>On-prem Deployment]
end
WebOS --> FastAPI
iPad --> FastAPI
FastAPI --> Oracle
FastAPI --> Sentinel
FastAPI --> Catalyst
Catalyst --> Comfy
Comfy --> AWS
Comfy --> S3
FastAPI --> Linux
style FastAPI fill:#e1f5fe
style Oracle fill:#f3e5f5
style Sentinel fill:#e8f5e8
style Catalyst fill:#fff3e0
```
### File Dependency Graph
```mermaid
graph TD
subgraph "Frontend (React/Vite)"
App[App.tsx<br/>Routing & Auth]
Store[useStore.ts<br/>Zustand State]
Components[Components/<br/>Modules & UI]
API[api.ts<br/>HTTP Client]
end
subgraph "Backend (FastAPI)"
Main[main.py<br/>App Entry]
Routers[routers/<br/>API Endpoints]
Services[services/<br/>Business Logic]
DB[db/<br/>Schema & Pool]
Auth[auth/<br/>JWT & Users]
Oracle[oracle/<br/>AI Intelligence]
end
subgraph "AI Infrastructure"
Comfy[comfy_engine/<br/>Media Generation]
Models[models/<br/>AI Models]
Prompts[nemoclaw_prompts/<br/>LLM Templates]
end
subgraph "Deployment"
Infra[infrastructure/<br/>Linux + AWS]
Agents[agents/<br/>Orchestration]
end
App --> API
API --> Main
Main --> Routers
Routers --> Services
Services --> DB
Services --> Auth
Services --> Oracle
Oracle --> Prompts
Comfy --> Infra
Infra --> Agents
style Main fill:#e3f2fd
style Oracle fill:#fce4ec
```
### Data Flow Architecture
```mermaid
flowchart LR
User[User Input] --> UI[WebOS/iPad UI]
UI --> API[FastAPI Endpoints]
API --> Auth[JWT Authentication]
API --> Policy[Policy Engine<br/>Authorization]
API --> LLM[Nemoclaw LLM<br/>Reasoning & Planning]
LLM --> Query[SQL Generation<br/>Safe Queries]
Query --> DB[(PostgreSQL<br/>CRM + Intelligence)]
DB --> Results[Query Results]
Results --> Viz[Visualization<br/>Components]
Viz --> Canvas[Oracle Canvas<br/>Persistent Views]
Canvas --> UI
Sentinel[Sentinel Biometric] --> WS[WebSocket<br/>Real-time]
WS --> Perception[Face Analysis<br/>MediaPipe]
Perception --> QD[QD Scoring<br/>NemoClaw]
QD --> DB
Catalyst[Catalyst Marketing] --> Comfy[ComfyUI<br/>Media Generation]
Comfy --> S3[S3 Assets]
Comfy --> GPU[AWS GPUs]
style DB fill:#fff9c4
style LLM fill:#e8f5e8
style Comfy fill:#fce4ec
```
## Logic Decomposition
### Authentication & Authorization
**What:** Secure access control for all system functions
**How:** JWT tokens with role-based permissions (ADMIN, SALES_DIRECTOR, SENIOR_BROKER, JUNIOR_BROKER)
**Why:** Real estate involves sensitive client data; strict access prevents unauthorized sales interference
**Key Implementation:**
- `backend/auth/dependencies.py`: JWT validation, user extraction, role enforcement
- `main.py`: Login endpoint with password hashing, user profile management
- Role hierarchy prevents junior brokers from approving high-value deals
### CRM Data Model
**What:** Canonical client and interaction records
**How:** PostgreSQL schema with leads, contacts, opportunities, interactions
**Why:** Real estate sales require accurate pipeline tracking and relationship history
**Key Tables:**
- `leads_intelligence`: Core lead data with QD scores and tags
- `omnichannel_logs`: All interactions (calls, emails, visits) with timestamps
- `crm_people/crm_leads`: Structured client relationships and deal stages
### Sentinel Biometric Intelligence
**What:** Real-time visitor sentiment analysis for showroom engagement
**How:** Browser webcam + MediaPipe face landmarking → blend shapes → QD scoring
**Why:** Human emotion drives buying decisions; AI detects subtle cues brokers miss
**Execution Flow:**
1. Browser captures video stream
2. MediaPipe extracts facial landmarks (68 points)
3. Blend shapes calculated (brow furrow, smile intensity, etc.)
4. NemoClaw LLM scores 1-100 QD (Qualification Desire)
5. Real-time dashboard updates for brokers
**Key Decision:** Browser-side processing preserves privacy (no video leaves client device)
### Oracle Natural Language Intelligence
**What:** AI-powered data analysis through conversational queries
**How:** Natural language → SQL planning → visualization components → canvas
**Why:** Brokers shouldn't need SQL knowledge; AI translates business questions to insights
**Architecture Layers:**
- **Prompt Orchestrator:** Decomposes complex requests into sub-queries
- **Natural DB Agent:** Schema introspection + safe SQL generation
- **Canvas Service:** Persistent visual workspaces with revision history
- **Collaboration:** Fork/merge workflows for team coordination
**Why Canvas?** Unlike transient chat responses, canvases persist as living documents
### Catalyst Marketing Orchestration
**What:** Automated campaign creation and asset generation
**How:** Meta Ads API integration + ComfyUI media workflows
**Why:** Luxury real estate needs high-quality visuals; manual creation is too slow
**Workflow:**
1. Campaign parameters → Meta API calls
2. Creative assets → ComfyUI (Dream Weaver, Wan 2.2, Qwen poster generation)
3. GPU processing on AWS → S3 storage
4. Performance tracking back to CRM
### Infrastructure & Deployment
**What:** Production-ready on-prem + cloud hybrid deployment
**How:** Linux control surface + AWS GPU workers + stable ingress
**Why:** Real estate firms demand data sovereignty; cloud-only solutions unacceptable
**Key Components:**
- **Ingress:** Caddy on EC2 t4g.micro with TLS termination
- **Backend:** Linux systemd services for FastAPI, ComfyUI, LLM runtime
- **GPU:** AWS managed instances for media generation
- **Assets:** S3 for model storage, NVMe for fast access
## Connectivity Matrix
| Component | Inputs | Outputs | Dependencies | Protocols |
|-----------|--------|---------|--------------|-----------|
| WebOS Frontend | User actions, API responses | UI renders, API calls | FastAPI backend | HTTP/WS, JWT |
| iPad App | Camera feeds, user input | Biometric data, inventory scans | FastAPI backend | HTTP/WS |
| FastAPI Core | API requests, WS connections | DB queries, AI responses | PostgreSQL, Redis (future) | SQL, HTTP |
| Oracle Engine | Natural language prompts | Canvas components | NemoClaw LLM, PostgreSQL | Internal API |
| Sentinel Engine | Webcam streams | QD scores, alerts | MediaPipe, NemoClaw | WS real-time |
| Catalyst Engine | Campaign specs | Ad creatives, assets | Meta API, ComfyUI | HTTP, S3 |
| ComfyUI | Generation requests | Images/videos | GPU workers, S3 | Internal queue |
| PostgreSQL | SQL queries | Structured data | - | SQL |
| AWS GPUs | Media jobs | Generated assets | ComfyUI workflows | SSH/tunnel |
**Interconnection Rationale:**
- **WebOS ↔ Backend:** Thin client architecture; all business logic server-side for security
- **Backend ↔ AI Services:** Modular design; each AI component (Oracle, Sentinel, Catalyst) operates independently but shares auth/policy
- **AI Services ↔ DB:** Direct SQL access with row-level security; no ORM abstraction to maintain performance
- **Infrastructure:** Hybrid on-prem/cloud; sensitive data stays on-prem, compute-intensive tasks use cloud GPUs
## First-Principles Guide
### Core Concept: AI-Augmented Sales Intelligence
At its foundation, Project Velocity operates on the principle that human sales professionals excel at relationship-building and deal-closing, while AI excels at pattern recognition, data synthesis, and repetitive analysis. The system doesn't replace brokers—it amplifies their capabilities by providing real-time insights they couldn't otherwise access.
**Why Real Estate Specifically?**
- High-value transactions ($M+ deals) with long sales cycles (months)
- Emotional decision-making influenced by subtle cues
- Complex data relationships (properties, buyers, markets, timing)
- Regulatory compliance requirements
- Need for visual storytelling (luxury properties)
### Principle 1: Data Sovereignty First
**Fundamental Truth:** Real estate firms own their client relationships. Project Velocity runs on-premise or in tenant-controlled cloud to maintain this ownership.
**Implementation:** Linux-based deployment with optional AWS GPU extensions. All client data remains within tenant boundaries; only anonymous model requests leave for AI processing.
### Principle 2: Real-Time Perception Matters
**Fundamental Truth:** Buying decisions happen in moments of emotional connection. Project Velocity captures these moments through biometric analysis.
**Implementation:** Sentinel uses facial expression analysis to score "Qualification Desire" (QD) on a 1-100 scale, alerting brokers to engagement spikes during property tours.
### Principle 3: Intelligence Through Conversation
**Fundamental Truth:** Sales professionals think in business terms, not database queries. The Oracle translates natural language into structured analytics.
**Implementation:** Users ask "Show me whale leads from Dubai this quarter" and receive visual dashboards. The system plans safe SQL queries, executes them, and renders results as persistent canvas components.
### Principle 4: Visual Storytelling Drives Sales
**Fundamental Truth:** Luxury properties sell through aspiration and emotion. AI-generated media must be photorealistic and brand-consistent.
**Implementation:** ComfyUI workflows (Dream Weaver, Wan 2.2) create property visualizations. Catalyst orchestrates campaigns with generated assets automatically uploaded to Meta Ads.
### Principle 5: Revision Control for Business Logic
**Fundamental Truth:** Sales strategies evolve through collaboration and iteration. Oracle canvases use Git-like branching for analytical workflows.
**Implementation:** Canvas pages support forks, merge requests, and revision history. Brokers can experiment with analysis approaches without breaking production views.
### Design Philosophy: Production-Ready Craft
Project Velocity follows "experienced engineer" principles:
- **Error Handling:** No silent failures; all errors surface useful messages
- **Type Safety:** TypeScript frontend, typed Python backend
- **Performance:** Async everywhere, connection pooling, efficient queries
- **Security:** JWT auth, role-based access, input validation
- **Observability:** Structured logging, health checks, WebSocket monitoring
- **Maintainability:** Clear separation of concerns, comprehensive documentation
### Why This Architecture Succeeds
1. **Modular AI Services:** Each intelligence component (Oracle, Sentinel, Catalyst) operates independently, allowing incremental improvement and specialized optimization.
2. **Hybrid Infrastructure:** Combines on-prem reliability with cloud scalability. Sensitive CRM data stays local; compute-intensive media generation uses managed GPUs.
3. **Real-Time Integration:** WebSockets enable live updates across all surfaces. Brokers see lead scoring changes instantly, dashboard metrics update in real-time.
4. **Business Logic in Code:** Revenue-critical workflows (lead qualification, campaign approval, deal closing) are explicit code paths, not AI hallucinations.
5. **User-Centric Design:** The WebOS feels like a native application, not a bolted-on AI interface. Familiar patterns (canvases, dashboards, forms) reduce training time.
This architecture transforms real estate sales from intuition-driven processes into data-augmented, AI-accelerated operations while preserving the human elements that drive luxury transactions.
## API Endpoints Reference
This section provides a comprehensive catalog of all API endpoints exposed by the Project Velocity backend, organized by functional module. Each endpoint includes the HTTP method, URI path, absolute HTTPS URL, and a brief description of its functionality. This serves as the definitive source of truth for the project's routing and interface architecture.
### Authentication Endpoints
| Method | Path | Absolute URL | Description |
|--------|------|--------------|-------------|
| POST | /api/auth/login | https://velocity.desineuron.in/api/auth/login | Authenticate a user with email/password and return JWT token |
| GET | /api/auth/me | https://velocity.desineuron.in/api/auth/me | Get current authenticated user's profile information |
| GET | /api/auth/users | https://velocity.desineuron.in/api/auth/users | List all active users in the system |
| POST | /api/auth/profile/avatar | https://velocity.desineuron.in/api/auth/profile/avatar | Upload and update user's profile avatar image |
### CRM Endpoints
| Method | Path | Absolute URL | Description |
|--------|------|--------------|-------------|
| GET | /api/leads | https://velocity.desineuron.in/api/leads | List leads with pagination and filtering |
| GET | /api/leads/{lead_id} | https://velocity.desineuron.in/api/leads/{lead_id} | Get detailed information for a specific lead |
| GET | /api/kanban/board | https://velocity.desineuron.in/api/kanban/board | Retrieve the kanban board view of leads by stage |
| GET | /api/chat-logs | https://velocity.desineuron.in/api/chat-logs | List chat logs for leads with optional lead filtering |
| GET | /api/leads/demographics | https://velocity.desineuron.in/api/leads/demographics | Get demographic analytics for leads (source, qualification) |
### Analytics Endpoints
| Method | Path | Absolute URL | Description |
|--------|------|--------------|-------------|
| GET | /api/analytics/sentiment-scatter | https://velocity.desineuron.in/api/analytics/sentiment-scatter | Get scatter plot data for sentiment analysis |
### Oracle AI Intelligence Endpoints
| Method | Path | Absolute URL | Description |
|--------|------|--------------|-------------|
| GET | /api/oracle/health | https://velocity.desineuron.in/api/oracle/health | Check Oracle system health and MCP tool availability |
| GET | /api/oracle/data-health | https://velocity.desineuron.in/api/oracle/data-health | Get data health metrics for database tables |
| GET | /api/oracle/schema-catalog | https://velocity.desineuron.in/api/oracle/schema-catalog | Retrieve schema catalog for database introspection |
| POST | /api/oracle/query | https://velocity.desineuron.in/api/oracle/query | Execute natural language query against database |
| GET | /api/oracle/mcp/tools | https://velocity.desineuron.in/api/oracle/mcp/tools | List available MCP tools for execution |
| POST | /api/oracle/mcp/execute | https://velocity.desineuron.in/api/oracle/mcp/execute | Execute an MCP tool with given query |
| POST | /api/oracle/workflow/preview | https://velocity.desineuron.in/api/oracle/workflow/preview | Preview workflow plan for a natural language prompt |
#### Oracle Canvas Management (v1)
| Method | Path | Absolute URL | Description |
|--------|------|--------------|-------------|
| POST | /api/oracle/v1/canvas/pages | https://velocity.desineuron.in/api/oracle/v1/canvas/pages | Create a new Oracle canvas page |
| GET | /api/oracle/v1/canvas/pages | https://velocity.desineuron.in/api/oracle/v1/canvas/pages | List user's Oracle canvas pages |
| GET | /api/oracle/v1/canvas/pages/{page_id} | https://velocity.desineuron.in/api/oracle/v1/canvas/pages/{page_id} | Get a specific canvas page |
| PUT | /api/oracle/v1/canvas/pages/{page_id} | https://velocity.desineuron.in/api/oracle/v1/canvas/pages/{page_id} | Update a canvas page |
| DELETE | /api/oracle/v1/canvas/pages/{page_id} | https://velocity.desineuron.in/api/oracle/v1/canvas/pages/{page_id} | Delete a canvas page |
| POST | /api/oracle/v1/canvas/pages/{page_id}/fork | https://velocity.desineuron.in/api/oracle/v1/canvas/pages/{page_id}/fork | Create a fork of a canvas page |
| POST | /api/oracle/v1/canvas/pages/{page_id}/merge | https://velocity.desineuron.in/api/oracle/v1/canvas/pages/{page_id}/merge | Merge changes into a canvas page |
| GET | /api/oracle/v1/canvas/pages/{page_id}/revisions | https://velocity.desineuron.in/api/oracle/v1/canvas/pages/{page_id}/revisions | List revisions for a canvas page |
| POST | /api/oracle/v1/canvas/pages/{page_id}/revisions | https://velocity.desineuron.in/api/oracle/v1/canvas/pages/{page_id}/revisions | Create new revision for a canvas page |
| GET | /api/oracle/v1/canvas/pages/{page_id}/revisions/{revision_id} | https://velocity.desineuron.in/api/oracle/v1/canvas/pages/{page_id}/revisions/{revision_id} | Get specific revision |
| PUT | /api/oracle/v1/canvas/pages/{page_id}/revisions/{revision_id} | https://velocity.desineuron.in/api/oracle/v1/canvas/pages/{page_id}/revisions/{revision_id} | Update a revision |
| DELETE | /api/oracle/v1/canvas/pages/{page_id}/revisions/{revision_id} | https://velocity.desineuron.in/api/oracle/v1/canvas/pages/{page_id}/revisions/{revision_id} | Delete a revision |
| GET | /api/oracle/v1/canvas/pages/{page_id}/revisions/{revision_id}/components | https://velocity.desineuron.in/api/oracle/v1/canvas/pages/{page_id}/revisions/{revision_id}/components | List components in a revision |
| POST | /api/oracle/v1/canvas/pages/{page_id}/revisions/{revision_id}/components | https://velocity.desineuron.in/api/oracle/v1/canvas/pages/{page_id}/revisions/{revision_id}/components | Add component to revision |
| PUT | /api/oracle/v1/canvas/pages/{page_id}/revisions/{revision_id}/components/{component_id} | https://velocity.desineuron.in/api/oracle/v1/canvas/pages/{page_id}/revisions/{revision_id}/components/{component_id} | Update component |
| DELETE | /api/oracle/v1/canvas/pages/{page_id}/revisions/{revision_id}/components/{component_id} | https://velocity.desineuron.in/api/oracle/v1/canvas/pages/{page_id}/revisions/{revision_id}/components/{component_id} | Delete component |
#### Oracle Template Management
| Method | Path | Absolute URL | Description |
|--------|------|--------------|-------------|
| GET | /api/oracle/template-chapters | https://velocity.desineuron.in/api/oracle/template-chapters | List Oracle template chapters |
| POST | /api/oracle/template-chapters | https://velocity.desineuron.in/api/oracle/template-chapters | Create new template chapter |
| GET | /api/oracle/template-subchapters | https://velocity.desineuron.in/api/oracle/template-subchapters | List Oracle template subchapters |
| POST | /api/oracle/template-subchapters | https://velocity.desineuron.in/api/oracle/template-subchapters | Create new template subchapter |
| GET | /api/oracle/component-templates | https://velocity.desineuron.in/api/oracle/component-templates | List Oracle component templates |
| POST | /api/oracle/component-templates | https://velocity.desineuron.in/api/oracle/component-templates | Create new component template |
| GET | /api/oracle/component-templates/{template_id} | https://velocity.desineuron.in/api/oracle/component-templates/{template_id} | Get specific component template |
| POST | /api/oracle/component-templates/{template_id}/seed | https://velocity.desineuron.in/api/oracle/component-templates/{template_id}/seed | Add seed example to template |
| GET | /api/oracle/component-templates/{template_id}/seed | https://velocity.desineuron.in/api/oracle/component-templates/{template_id}/seed | List seed examples for template |
| POST | /api/oracle/component-templates/synthetic-jobs | https://velocity.desineuron.in/api/oracle/component-templates/synthetic-jobs | Submit synthetic template generation job |
### Sentinel Biometric Intelligence Endpoints
| Method | Path | Absolute URL | Description |
|--------|------|--------------|-------------|
| WebSocket | /api/sentinel/ws/notifications | wss://velocity.desineuron.in/api/sentinel/ws/notifications | Real-time notifications WebSocket |
| WebSocket | /api/sentinel/ws/perception | wss://velocity.desineuron.in/api/sentinel/ws/perception | Biometric perception data WebSocket |
| POST | /api/sentinel/consent | https://velocity.desineuron.in/api/sentinel/consent | Record biometric consent for lead |
| POST | /api/sentinel/session/complete | https://velocity.desineuron.in/api/sentinel/session/complete | Close a perception session and finalize QD score |
| POST | /api/sentinel/tag-lead | https://velocity.desineuron.in/api/sentinel/tag-lead | Apply NemoClaw lead tagging to CRM lead |
| GET | /api/sentinel/qd-score/{lead_id} | https://velocity.desineuron.in/api/sentinel/qd-score/{lead_id} | Get current QD score for a lead |
### Catalyst Marketing Orchestration Endpoints
| Method | Path | Absolute URL | Description |
|--------|------|--------------|-------------|
| POST | /api/catalyst/campaigns/create | https://velocity.desineuron.in/api/catalyst/campaigns/create | Bulk create Meta ad campaigns |
| POST | /api/catalyst/creative/sync | https://velocity.desineuron.in/api/catalyst/creative/sync | Upload ComfyUI assets to Meta |
| GET | /api/catalyst/insights/realtime | https://velocity.desineuron.in/api/catalyst/insights/realtime | Poll Meta Ads Insights API |
| POST | /api/catalyst/audiences/lookalike | https://velocity.desineuron.in/api/catalyst/audiences/lookalike | Push CRM leads to Meta Custom Audience |
| POST | /api/catalyst/auth/meta | https://velocity.desineuron.in/api/catalyst/auth/meta | OAuth token acquisition for Meta |
### Vault Trackable Links Endpoints
| Method | Path | Absolute URL | Description |
|--------|------|--------------|-------------|
| POST | /api/vault/generate-link | https://velocity.desineuron.in/api/vault/generate-link | Generate trackable URL for shared asset |
| GET | /vault/{tracking_hash} | https://velocity.desineuron.in/vault/{tracking_hash} | Public access to trackable vault link (no auth required) |
### CCTV Surveillance Integration Endpoints
| Method | Path | Absolute URL | Description |
|--------|------|--------------|-------------|
| POST | /api/cctv/event | https://velocity.desineuron.in/api/cctv/event | Ingest CCTV frame event from RTSP/ONVIF bridge |
| POST | /api/cctv/finalize-auto-mode | https://velocity.desineuron.in/api/cctv/finalize-auto-mode | Match or create lead after auto-mode session |
### Video Scene Mapping Endpoints
| Method | Path | Absolute URL | Description |
|--------|------|--------------|-------------|
| POST | /api/scenes/upload | https://velocity.desineuron.in/api/scenes/upload | Upload CSV scene map for marketing video |
| GET | /api/scenes/{video_asset_id} | https://velocity.desineuron.in/api/scenes/{video_asset_id} | Get scene map for specific video asset |
### Marketing Videos Endpoints
| Method | Path | Absolute URL | Description |
|--------|------|--------------|-------------|
| GET | /api/videos/marketing | https://velocity.desineuron.in/api/videos/marketing | List marketing videos available for Sentinel sessions |
### Mobile Edge Communication Endpoints
| Method | Path | Absolute URL | Description |
|--------|------|--------------|-------------|
| GET | /api/mobile-edge/events | https://velocity.desineuron.in/api/mobile-edge/events | List communication events for a lead |
| POST | /api/mobile-edge/events | https://velocity.desineuron.in/api/mobile-edge/events | Log new communication event |
| GET | /api/mobile-edge/memory | https://velocity.desineuron.in/api/mobile-edge/memory | List memory facts for a lead |
| POST | /api/mobile-edge/imports | https://velocity.desineuron.in/api/mobile-edge/imports | Operator-assisted import of recording/note |
| POST | /api/mobile-edge/notes | https://velocity.desineuron.in/api/mobile-edge/notes | Quick note attachment to lead |
| GET | /api/mobile-edge/calendar | https://velocity.desineuron.in/api/mobile-edge/calendar | Calendar events for authenticated user |
| POST | /api/mobile-edge/calendar | https://velocity.desineuron.in/api/mobile-edge/calendar | Create calendar event |
| PATCH | /api/mobile-edge/calendar/{calendar_event_id} | https://velocity.desineuron.in/api/mobile-edge/calendar/{calendar_event_id} | Update calendar event |
| DELETE | /api/mobile-edge/calendar/{calendar_event_id} | https://velocity.desineuron.in/api/mobile-edge/calendar/{calendar_event_id} | Cancel calendar event |
| GET | /api/mobile-edge/transcripts/{event_id} | https://velocity.desineuron.in/api/mobile-edge/transcripts/{event_id} | Transcript segments for event |
| GET | /api/mobile-edge/insights/{lead_id} | https://velocity.desineuron.in/api/mobile-edge/insights/{lead_id} | Insight recommendations for lead |
| POST | /api/mobile-edge/insights/{recommendation_id}/act | https://velocity.desineuron.in/api/mobile-edge/insights/{recommendation_id}/act | Act on or dismiss insight |
| GET | /api/mobile-edge/alerts | https://velocity.desineuron.in/api/mobile-edge/alerts | Active alerts for authenticated user |
| POST | /api/mobile-edge/session | https://velocity.desineuron.in/api/mobile-edge/session | Register surface session heartbeat |
### Inventory Management Endpoints
| Method | Path | Absolute URL | Description |
|--------|------|--------------|-------------|
| POST | /api/inventory/import-batches | https://velocity.desineuron.in/api/inventory/import-batches | Create inventory import batch |
| GET | /api/inventory/import-batches | https://velocity.desineuron.in/api/inventory/import-batches | List import batches |
| GET | /api/inventory/import-batches/{batch_id} | https://velocity.desineuron.in/api/inventory/import-batches/{batch_id} | Get batch status |
| POST | /api/inventory/properties | https://velocity.desineuron.in/api/inventory/properties | Create single property |
| GET | /api/inventory/properties | https://velocity.desineuron.in/api/inventory/properties | List inventory properties |
| GET | /api/inventory/properties/{property_id} | https://velocity.desineuron.in/api/inventory/properties/{property_id} | Get property details |
| PATCH | /api/inventory/properties/{property_id} | https://velocity.desineuron.in/api/inventory/properties/{property_id} | Update property |
| DELETE | /api/inventory/properties/{property_id} | https://velocity.desineuron.in/api/inventory/properties/{property_id} | Archive property |
| POST | /api/inventory/properties/{property_id}/media | https://velocity.desineuron.in/api/inventory/properties/{property_id}/media | Attach media to property |
| GET | /api/inventory/properties/{property_id}/media | https://velocity.desineuron.in/api/inventory/properties/{property_id}/media | List media for property |
| DELETE | /api/inventory/media/{media_asset_id} | https://velocity.desineuron.in/api/inventory/media/{media_asset_id} | Remove media asset |
### Admin Surface Control Endpoints
| Method | Path | Absolute URL | Description |
|--------|------|--------------|-------------|
| GET | /api/admin-surface/health | https://velocity.desineuron.in/api/admin-surface/health | System health overview |
| GET | /api/admin-surface/queues | https://velocity.desineuron.in/api/admin-surface/queues | Queue depth snapshot |
| GET | /api/admin-surface/installs | https://velocity.desineuron.in/api/admin-surface/installs | Surface session/install overview |
| POST | /api/admin-surface/actions | https://velocity.desineuron.in/api/admin-surface/actions | Submit admin action |
| GET | /api/admin-surface/actions | https://velocity.desineuron.in/api/admin-surface/actions | List admin action history |
| GET | /api/admin-surface/actions/{action_event_id} | https://velocity.desineuron.in/api/admin-surface/actions/{action_event_id} | Get specific admin action |
| GET | /api/admin-surface/logs | https://velocity.desineuron.in/api/admin-surface/logs | Recent Oracle audit event log |
| GET | /api/admin-surface/templates | https://velocity.desineuron.in/api/admin-surface/templates | Template catalog admin view |
| POST | /api/admin-surface/templates/{template_id}/publish | https://velocity.desineuron.in/api/admin-surface/templates/{template_id}/publish | Publish template |
| POST | /api/admin-surface/templates/{template_id}/archive | https://velocity.desineuron.in/api/admin-surface/templates/{template_id}/archive | Archive template |
| GET | /api/admin-surface/template-chapters | https://velocity.desineuron.in/api/admin-surface/template-chapters | List template chapters (admin) |
| GET | /api/admin-surface/synthetic-jobs | https://velocity.desineuron.in/api/admin-surface/synthetic-jobs | List synthetic generation jobs |
| POST | /api/admin-surface/synthetic-jobs/{job_id}/cancel | https://velocity.desineuron.in/api/admin-surface/synthetic-jobs/{job_id}/cancel | Cancel synthetic job |
### CRM Canonical Data Endpoints
| Method | Path | Absolute URL | Description |
|--------|------|--------------|-------------|
| POST | /api/crm/imports | https://velocity.desineuron.in/api/crm/imports | Upload CSV batch for import |
| GET | /api/crm/imports | https://velocity.desineuron.in/api/crm/imports | List import batches |
| GET | /api/crm/imports/{batch_id} | https://velocity.desineuron.in/api/crm/imports/{batch_id} | Get batch detail and proposals |
| PUT | /api/crm/imports/{batch_id}/review-proposal | https://velocity.desineuron.in/api/crm/imports/{batch_id}/review-proposal | Review import proposal |
| POST | /api/crm/imports/{batch_id}/commit | https://velocity.desineuron.in/api/crm/imports/{batch_id}/commit | Commit approved proposals |
| GET | /api/crm/contacts | https://velocity.desineuron.in/api/crm/contacts | Canonical contact list with QD summary |
| POST | /api/crm/contacts | https://velocity.desineuron.in/api/crm/contacts | Create new contact |
| GET | /api/crm/contacts/{person_id} | https://velocity.desineuron.in/api/crm/contacts/{person_id} | Canonical contact detail |
| GET | /api/crm/client-360/{person_id} | https://velocity.desineuron.in/api/crm/client-360/{person_id} | Client 360 aggregated snapshot |
| GET | /api/crm/opportunities | https://velocity.desineuron.in/api/crm/opportunities | Opportunity pipeline list |
| GET | /api/crm/tasks | https://velocity.desineuron.in/api/crm/tasks | Reminder/task list |
| POST | /api/crm/tasks | https://velocity.desineuron.in/api/crm/tasks | Create new task |
| GET | /api/crm/kanban | https://velocity.desineuron.in/api/crm/kanban | Kanban board (canonical leads) |
| GET | /api/crm/qd/{person_id} | https://velocity.desineuron.in/api/crm/qd/{person_id} | QD score history for person |
| GET | /api/crm/client-data | https://velocity.desineuron.in/api/crm/client-data | List client data records |
| GET | /api/crm/client-data/{person_id} | https://velocity.desineuron.in/api/crm/client-data/{person_id} | Get client data for person |
| PATCH | /api/crm/client-data/{person_id} | https://velocity.desineuron.in/api/crm/client-data/{person_id} | Update client data |
| GET | /api/crm/client-data/{person_id}/timeline | https://velocity.desineuron.in/api/crm/client-data/{person_id}/timeline | Client data timeline |
| POST | /api/crm/client-data/{person_id}/tasks | https://velocity.desineuron.in/api/crm/client-data/{person_id}/tasks | Create task for client |
### Runtime LLM Endpoints
Method Path Absolute URL Description
GET /api/runtime/llm/providers https://velocity.desineuron.in/api/runtime/llm/providers List configured LLM providers and models
POST /api/runtime/llm/chat https://velocity.desineuron.in/api/runtime/llm/chat Execute single LLM chat completion
POST /api/runtime/llm/batch https://velocity.desineuron.in/api/runtime/llm/batch Submit persisted LLM batch job
GET /api/runtime/llm/jobs/{job_id} https://velocity.desineuron.in/api/runtime/llm/jobs/{job_id} Get batch job status
GET /api/runtime/llm/jobs/{job_id}/results https://velocity.desineuron.in/api/runtime/llm/jobs/{job_id}/results Get batch job results
### Infrastructure Notes
- **Caddy Reverse Proxy**: All endpoints are served through Caddy on port 443, proxying to FastAPI on localhost:8443 with TLS termination
- **Authentication**: JWT-based auth required for most endpoints (except public vault links)
- **WebSockets**: Real-time features use WebSocket connections for live updates
- **Role-Based Access**: Endpoints enforce role permissions (SENIOR_BROKER, ADMIN, etc.)
- **Tenant Isolation**: Multi-tenant architecture with tenant_id scoping
- **Audit Logging**: All mutations create immutable audit records
- **Health Checks**: System provides comprehensive health and queue monitoring endpoints

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,63 @@
name: Production Readiness
on:
pull_request:
push:
branches:
- main
- master
jobs:
backend-contracts:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install backend dependencies
run: |
python -m pip install --upgrade pip
pip install -r backend/requirements.txt
pip install pytest
- name: Run backend contract tests
run: |
PYTHONPATH="$PWD" python -m pytest \
backend/tests/test_auth_tenant_contract.py \
backend/tests/test_canonical_crm_auth.py \
backend/tests/test_canonical_crm_tenant_scoping.py \
backend/tests/test_dream_weaver_gateway_auth.py \
backend/tests/test_migrations_and_observability.py \
backend/tests/test_surface_route_tenant_scoping.py
webos-typecheck:
runs-on: ubuntu-latest
defaults:
run:
working-directory: app
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: app/package-lock.json
- name: Install WebOS dependencies
run: npm ci
- name: Typecheck WebOS
run: npx tsc --noEmit
ipad-parse:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Parse active iPad Swift sources
run: |
swiftc -frontend -parse \
iOS/velocity-ipad/velocity/App/ContentView.swift \
iOS/velocity-ipad/velocity/Features/Clients/ClientsView.swift \
iOS/velocity-ipad/velocity/Features/Imports/ImportsView.swift \
iOS/velocity-ipad/velocity/Core/Networking/VelocityAPIClient.swift \
iOS/velocity-ipad/velocity/Core/State/AppStore.swift \
iOS/velocity-ipad/velocityTests/VelocitySmokeTests.swift

4
app/dist/index.html vendored
View File

@@ -4,8 +4,8 @@
<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-Bj2Xa_13.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-W2SBxMnB.css">
<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>

View File

@@ -1 +1 @@
{"root":["../../src/app.tsx","../../src/main.tsx","../../src/app/admin/page.tsx","../../src/app/oracle/page.tsx","../../src/components/layout/loginscreen.tsx","../../src/components/layout/notificationcenter.tsx","../../src/components/layout/sidebar.tsx","../../src/components/modules/crm.tsx","../../src/components/modules/catalyst.tsx","../../src/components/modules/catalystmarketingtab.tsx","../../src/components/modules/dashboard.tsx","../../src/components/modules/groundtruthpicker.tsx","../../src/components/modules/inventory.tsx","../../src/components/modules/oracle.tsx","../../src/components/modules/sentinel.tsx","../../src/components/modules/settings.tsx","../../src/components/modules/sentinel/perceptionplayer.tsx","../../src/components/modules/sentinel/sentinellivesession.tsx","../../src/components/oracle/leadinspector.tsx","../../src/components/oracle/pipelineview.tsx","../../src/components/sentinel/journeyriver/inspectorpanel.tsx","../../src/components/sentinel/journeyriver/riverpath.tsx","../../src/components/sentinel/journeyriver/index.tsx","../../src/components/ui/accordion.tsx","../../src/components/ui/alert-dialog.tsx","../../src/components/ui/alert.tsx","../../src/components/ui/aspect-ratio.tsx","../../src/components/ui/avatar.tsx","../../src/components/ui/badge.tsx","../../src/components/ui/breadcrumb.tsx","../../src/components/ui/button-group.tsx","../../src/components/ui/button.tsx","../../src/components/ui/calendar.tsx","../../src/components/ui/card.tsx","../../src/components/ui/carousel.tsx","../../src/components/ui/chart.tsx","../../src/components/ui/checkbox.tsx","../../src/components/ui/collapsible.tsx","../../src/components/ui/command.tsx","../../src/components/ui/context-menu.tsx","../../src/components/ui/dialog.tsx","../../src/components/ui/drawer.tsx","../../src/components/ui/dropdown-menu.tsx","../../src/components/ui/empty.tsx","../../src/components/ui/field.tsx","../../src/components/ui/form.tsx","../../src/components/ui/hover-card.tsx","../../src/components/ui/input-group.tsx","../../src/components/ui/input-otp.tsx","../../src/components/ui/input.tsx","../../src/components/ui/item.tsx","../../src/components/ui/kbd.tsx","../../src/components/ui/label.tsx","../../src/components/ui/menubar.tsx","../../src/components/ui/navigation-menu.tsx","../../src/components/ui/pagination.tsx","../../src/components/ui/popover.tsx","../../src/components/ui/progress.tsx","../../src/components/ui/radio-group.tsx","../../src/components/ui/resizable.tsx","../../src/components/ui/scroll-area.tsx","../../src/components/ui/select.tsx","../../src/components/ui/separator.tsx","../../src/components/ui/sheet.tsx","../../src/components/ui/sidebar.tsx","../../src/components/ui/skeleton.tsx","../../src/components/ui/slider.tsx","../../src/components/ui/sonner.tsx","../../src/components/ui/spinner.tsx","../../src/components/ui/switch.tsx","../../src/components/ui/table.tsx","../../src/components/ui/tabs.tsx","../../src/components/ui/textarea.tsx","../../src/components/ui/toggle-group.tsx","../../src/components/ui/toggle.tsx","../../src/components/ui/tooltip.tsx","../../src/hooks/use-mobile.ts","../../src/hooks/usecrmbootstrap.ts","../../src/hooks/usemediapipefacelandmarker.ts","../../src/hooks/usevelocitysocket.ts","../../src/lib/api.ts","../../src/lib/crmapi.ts","../../src/lib/crmmappers.ts","../../src/lib/platformmappers.ts","../../src/lib/utils.ts","../../src/lib/velocityplatformclient.ts","../../src/oracle/components/branchbar.tsx","../../src/oracle/components/canvasviewport.tsx","../../src/oracle/components/componentregistry.tsx","../../src/oracle/components/promptrail.tsx","../../src/oracle/components/rollbackconfirmmodal.tsx","../../src/oracle/components/sharemodal.tsx","../../src/oracle/components/renderers/activitystreamrenderer.tsx","../../src/oracle/components/renderers/barchartrenderer.tsx","../../src/oracle/components/renderers/errornoticerenderer.tsx","../../src/oracle/components/renderers/geomaprenderer.tsx","../../src/oracle/components/renderers/kpitilerenderer.tsx","../../src/oracle/components/renderers/linechartrenderer.tsx","../../src/oracle/components/renderers/pipelineboardrenderer.tsx","../../src/oracle/components/renderers/rendererwrapper.tsx","../../src/oracle/components/renderers/tablerenderer.tsx","../../src/oracle/components/renderers/timelinerenderer.tsx","../../src/oracle/components/review/mergereviewdrawer.tsx","../../src/oracle/hooks/useoracleexecution.ts","../../src/oracle/hooks/useoraclepage.ts","../../src/oracle/lib/oracleapiclient.ts","../../src/oracle/types/canvas.ts","../../src/store/usecurrencystore.ts","../../src/store/usemarketingstore.ts","../../src/store/usestore.ts","../../src/types/crm.ts","../../src/types/crmtypes.ts","../../src/types/index.ts","../../src/utils/curvegenerator.ts","../../src/utils/landmarkpacketencoder.ts"],"version":"5.9.3"}
{"root":["../../src/app.tsx","../../src/main.tsx","../../src/app/admin/page.tsx","../../src/app/oracle/page.tsx","../../src/components/layout/loginscreen.tsx","../../src/components/layout/notificationcenter.tsx","../../src/components/layout/sidebar.tsx","../../src/components/modules/crm.tsx","../../src/components/modules/catalyst.tsx","../../src/components/modules/catalystdreamweavertab.tsx","../../src/components/modules/catalystmarketingtab.tsx","../../src/components/modules/dashboard.tsx","../../src/components/modules/groundtruthpicker.tsx","../../src/components/modules/inventory.tsx","../../src/components/modules/oracle.tsx","../../src/components/modules/sentinel.tsx","../../src/components/modules/settings.tsx","../../src/components/modules/sentinel/perceptionplayer.tsx","../../src/components/modules/sentinel/sentinellivesession.tsx","../../src/components/oracle/leadinspector.tsx","../../src/components/oracle/pipelineview.tsx","../../src/components/sentinel/journeyriver/inspectorpanel.tsx","../../src/components/sentinel/journeyriver/riverpath.tsx","../../src/components/sentinel/journeyriver/index.tsx","../../src/components/ui/accordion.tsx","../../src/components/ui/alert-dialog.tsx","../../src/components/ui/alert.tsx","../../src/components/ui/aspect-ratio.tsx","../../src/components/ui/avatar.tsx","../../src/components/ui/badge.tsx","../../src/components/ui/breadcrumb.tsx","../../src/components/ui/button-group.tsx","../../src/components/ui/button.tsx","../../src/components/ui/calendar.tsx","../../src/components/ui/card.tsx","../../src/components/ui/carousel.tsx","../../src/components/ui/chart.tsx","../../src/components/ui/checkbox.tsx","../../src/components/ui/collapsible.tsx","../../src/components/ui/command.tsx","../../src/components/ui/context-menu.tsx","../../src/components/ui/dialog.tsx","../../src/components/ui/drawer.tsx","../../src/components/ui/dropdown-menu.tsx","../../src/components/ui/empty.tsx","../../src/components/ui/field.tsx","../../src/components/ui/form.tsx","../../src/components/ui/hover-card.tsx","../../src/components/ui/input-group.tsx","../../src/components/ui/input-otp.tsx","../../src/components/ui/input.tsx","../../src/components/ui/item.tsx","../../src/components/ui/kbd.tsx","../../src/components/ui/label.tsx","../../src/components/ui/menubar.tsx","../../src/components/ui/navigation-menu.tsx","../../src/components/ui/pagination.tsx","../../src/components/ui/popover.tsx","../../src/components/ui/progress.tsx","../../src/components/ui/radio-group.tsx","../../src/components/ui/resizable.tsx","../../src/components/ui/scroll-area.tsx","../../src/components/ui/select.tsx","../../src/components/ui/separator.tsx","../../src/components/ui/sheet.tsx","../../src/components/ui/sidebar.tsx","../../src/components/ui/skeleton.tsx","../../src/components/ui/slider.tsx","../../src/components/ui/sonner.tsx","../../src/components/ui/spinner.tsx","../../src/components/ui/switch.tsx","../../src/components/ui/table.tsx","../../src/components/ui/tabs.tsx","../../src/components/ui/textarea.tsx","../../src/components/ui/toggle-group.tsx","../../src/components/ui/toggle.tsx","../../src/components/ui/tooltip.tsx","../../src/hooks/use-mobile.ts","../../src/hooks/usecrmbootstrap.ts","../../src/hooks/usemediapipefacelandmarker.ts","../../src/hooks/usevelocitysocket.ts","../../src/lib/api.ts","../../src/lib/crmapi.ts","../../src/lib/crmmappers.ts","../../src/lib/dreamweaverapi.ts","../../src/lib/platformmappers.ts","../../src/lib/utils.ts","../../src/lib/velocityplatformclient.ts","../../src/lib/velocitysession.ts","../../src/oracle/components/branchbar.tsx","../../src/oracle/components/canvasviewport.tsx","../../src/oracle/components/componentregistry.tsx","../../src/oracle/components/promptrail.tsx","../../src/oracle/components/rollbackconfirmmodal.tsx","../../src/oracle/components/sharemodal.tsx","../../src/oracle/components/renderers/activitystreamrenderer.tsx","../../src/oracle/components/renderers/barchartrenderer.tsx","../../src/oracle/components/renderers/errornoticerenderer.tsx","../../src/oracle/components/renderers/geomaprenderer.tsx","../../src/oracle/components/renderers/kpitilerenderer.tsx","../../src/oracle/components/renderers/linechartrenderer.tsx","../../src/oracle/components/renderers/pipelineboardrenderer.tsx","../../src/oracle/components/renderers/rendererwrapper.tsx","../../src/oracle/components/renderers/tablerenderer.tsx","../../src/oracle/components/renderers/timelinerenderer.tsx","../../src/oracle/components/review/mergereviewdrawer.tsx","../../src/oracle/hooks/useoracleexecution.ts","../../src/oracle/hooks/useoraclepage.ts","../../src/oracle/lib/oracleapiclient.ts","../../src/oracle/types/canvas.ts","../../src/store/usecurrencystore.ts","../../src/store/usemarketingstore.ts","../../src/store/usestore.ts","../../src/types/crm.ts","../../src/types/crmtypes.ts","../../src/types/index.ts","../../src/utils/curvegenerator.ts","../../src/utils/landmarkpacketencoder.ts"],"version":"5.9.3"}

View File

@@ -1,18 +1,18 @@
"use client";
import {
require_shim
} from "./chunk-TXHHHGR3.js";
import {
useCallbackRef,
useLayoutEffect2
} from "./chunk-J4JAFMOP.js";
import {
require_react_dom
} from "./chunk-YF4B4G2L.js";
import {
createSlot
} from "./chunk-YWBEB5PG.js";
import "./chunk-2VUH7NEY.js";
import {
require_shim
} from "./chunk-TXHHHGR3.js";
import {
require_react_dom
} from "./chunk-YF4B4G2L.js";
import {
require_jsx_runtime
} from "./chunk-2YVA4HRZ.js";

View File

@@ -3,13 +3,13 @@ import {
useCallbackRef,
useLayoutEffect2
} from "./chunk-J4JAFMOP.js";
import {
require_react_dom
} from "./chunk-YF4B4G2L.js";
import {
composeRefs,
useComposedRefs
} from "./chunk-2VUH7NEY.js";
import {
require_react_dom
} from "./chunk-YF4B4G2L.js";
import {
require_jsx_runtime
} from "./chunk-2YVA4HRZ.js";

View File

@@ -1,12 +1,9 @@
import {
_extends
} from "./chunk-EQCCHGRT.js";
import {
create
} from "./chunk-7GZ4CI6Q.js";
import {
subscribeWithSelector
} from "./chunk-O4L7C4YS.js";
import {
Events
} from "./chunk-OAEA5FZL.js";
import {
addAfterEffect,
addEffect,
@@ -24,6 +21,9 @@ import {
useThree
} from "./chunk-5ESDTKMP.js";
import "./chunk-NJ4V5H3P.js";
import {
subscribeWithSelector
} from "./chunk-O4L7C4YS.js";
import {
AddEquation,
AdditiveBlending,
@@ -218,13 +218,13 @@ import {
WireframeGeometry,
ZeroFactor
} from "./chunk-L3Z576C2.js";
import {
Events
} from "./chunk-OAEA5FZL.js";
import {
require_client
} from "./chunk-6MXH2QM6.js";
import "./chunk-GUQHL3N7.js";
import {
_extends
} from "./chunk-EQCCHGRT.js";
import "./chunk-TXHHHGR3.js";
import "./chunk-YF4B4G2L.js";
import "./chunk-2YVA4HRZ.js";

View File

@@ -1,133 +1,133 @@
{
"hash": "4594f192",
"hash": "d63ca5ca",
"configHash": "1dd3b956",
"lockfileHash": "e8550e82",
"browserHash": "7e7e8c10",
"lockfileHash": "db47663b",
"browserHash": "b8dcfecc",
"optimized": {
"react": {
"src": "../../react/index.js",
"file": "react.js",
"fileHash": "bc0c1f26",
"fileHash": "0c4ff044",
"needsInterop": true
},
"react-dom": {
"src": "../../react-dom/index.js",
"file": "react-dom.js",
"fileHash": "36a8d9c0",
"fileHash": "d9b3477a",
"needsInterop": true
},
"react/jsx-dev-runtime": {
"src": "../../react/jsx-dev-runtime.js",
"file": "react_jsx-dev-runtime.js",
"fileHash": "3d8f6460",
"fileHash": "60584ffa",
"needsInterop": true
},
"react/jsx-runtime": {
"src": "../../react/jsx-runtime.js",
"file": "react_jsx-runtime.js",
"fileHash": "6f4aca26",
"fileHash": "0909256b",
"needsInterop": true
},
"@radix-ui/react-avatar": {
"src": "../../@radix-ui/react-avatar/dist/index.mjs",
"file": "@radix-ui_react-avatar.js",
"fileHash": "2a702dd2",
"fileHash": "3fc2fdda",
"needsInterop": false
},
"@radix-ui/react-dropdown-menu": {
"src": "../../@radix-ui/react-dropdown-menu/dist/index.mjs",
"file": "@radix-ui_react-dropdown-menu.js",
"fileHash": "a5efb9bf",
"fileHash": "eef7ef00",
"needsInterop": false
},
"@radix-ui/react-slot": {
"src": "../../@radix-ui/react-slot/dist/index.mjs",
"file": "@radix-ui_react-slot.js",
"fileHash": "986d9c0d",
"fileHash": "6745f8b7",
"needsInterop": false
},
"@react-three/drei": {
"src": "../../@react-three/drei/index.js",
"file": "@react-three_drei.js",
"fileHash": "6cd60875",
"fileHash": "62f4e280",
"needsInterop": false
},
"@react-three/fiber": {
"src": "../../@react-three/fiber/dist/react-three-fiber.esm.js",
"file": "@react-three_fiber.js",
"fileHash": "27a7d4df",
"fileHash": "c4b868b0",
"needsInterop": false
},
"class-variance-authority": {
"src": "../../class-variance-authority/dist/index.mjs",
"file": "class-variance-authority.js",
"fileHash": "b0c32b93",
"fileHash": "db4ee666",
"needsInterop": false
},
"clsx": {
"src": "../../clsx/dist/clsx.mjs",
"file": "clsx.js",
"fileHash": "c855e729",
"fileHash": "0a67ca45",
"needsInterop": false
},
"framer-motion": {
"src": "../../framer-motion/dist/es/index.mjs",
"file": "framer-motion.js",
"fileHash": "e0841dfa",
"fileHash": "9694d550",
"needsInterop": false
},
"lucide-react": {
"src": "../../lucide-react/dist/esm/lucide-react.js",
"file": "lucide-react.js",
"fileHash": "4d79a586",
"fileHash": "15d2dc31",
"needsInterop": false
},
"react-dom/client": {
"src": "../../react-dom/client.js",
"file": "react-dom_client.js",
"fileHash": "2e02376b",
"fileHash": "a8f9db58",
"needsInterop": true
},
"react-router-dom": {
"src": "../../react-router-dom/dist/index.mjs",
"file": "react-router-dom.js",
"fileHash": "bd4cf4c4",
"fileHash": "3a519f93",
"needsInterop": false
},
"recharts": {
"src": "../../recharts/es6/index.js",
"file": "recharts.js",
"fileHash": "b44545db",
"fileHash": "1cac0e9f",
"needsInterop": false
},
"sonner": {
"src": "../../sonner/dist/index.mjs",
"file": "sonner.js",
"fileHash": "02632b99",
"fileHash": "1ad92981",
"needsInterop": false
},
"tailwind-merge": {
"src": "../../tailwind-merge/dist/bundle-mjs.mjs",
"file": "tailwind-merge.js",
"fileHash": "ab22bcc4",
"fileHash": "e2d07b44",
"needsInterop": false
},
"three": {
"src": "../../three/build/three.module.js",
"file": "three.js",
"fileHash": "43012f83",
"fileHash": "09fb4882",
"needsInterop": false
},
"zustand": {
"src": "../../zustand/esm/index.mjs",
"file": "zustand.js",
"fileHash": "dbfba0e2",
"fileHash": "4607d0bf",
"needsInterop": false
},
"zustand/middleware": {
"src": "../../zustand/esm/middleware.mjs",
"file": "zustand_middleware.js",
"fileHash": "e524c2dc",
"fileHash": "e4fd4342",
"needsInterop": false
}
},
@@ -135,57 +135,57 @@
"hls-Q6LDPZPT": {
"file": "hls-Q6LDPZPT.js"
},
"chunk-U7P2NEEE": {
"file": "chunk-U7P2NEEE.js"
},
"chunk-J4JAFMOP": {
"file": "chunk-J4JAFMOP.js"
},
"chunk-YWBEB5PG": {
"file": "chunk-YWBEB5PG.js"
},
"chunk-2VUH7NEY": {
"file": "chunk-2VUH7NEY.js"
"chunk-EQCCHGRT": {
"file": "chunk-EQCCHGRT.js"
},
"chunk-7GZ4CI6Q": {
"file": "chunk-7GZ4CI6Q.js"
},
"chunk-O4L7C4YS": {
"file": "chunk-O4L7C4YS.js"
},
"chunk-OAEA5FZL": {
"file": "chunk-OAEA5FZL.js"
},
"chunk-5ESDTKMP": {
"file": "chunk-5ESDTKMP.js"
},
"chunk-NJ4V5H3P": {
"file": "chunk-NJ4V5H3P.js"
},
"chunk-O4L7C4YS": {
"file": "chunk-O4L7C4YS.js"
},
"chunk-L3Z576C2": {
"file": "chunk-L3Z576C2.js"
},
"chunk-OAEA5FZL": {
"file": "chunk-OAEA5FZL.js"
},
"chunk-6MXH2QM6": {
"file": "chunk-6MXH2QM6.js"
},
"chunk-GUQHL3N7": {
"file": "chunk-GUQHL3N7.js"
},
"chunk-EQCCHGRT": {
"file": "chunk-EQCCHGRT.js"
},
"chunk-TXHHHGR3": {
"file": "chunk-TXHHHGR3.js"
},
"chunk-J4JAFMOP": {
"file": "chunk-J4JAFMOP.js"
},
"chunk-YF4B4G2L": {
"file": "chunk-YF4B4G2L.js"
},
"chunk-YWBEB5PG": {
"file": "chunk-YWBEB5PG.js"
},
"chunk-2VUH7NEY": {
"file": "chunk-2VUH7NEY.js"
},
"chunk-2YVA4HRZ": {
"file": "chunk-2YVA4HRZ.js"
},
"chunk-WUR7D6NS": {
"file": "chunk-WUR7D6NS.js"
},
"chunk-U7P2NEEE": {
"file": "chunk-U7P2NEEE.js"
},
"chunk-G3PMV62Z": {
"file": "chunk-G3PMV62Z.js"
}

View File

@@ -1,6 +1,3 @@
import {
clsx_default
} from "./chunk-U7P2NEEE.js";
import {
_extends
} from "./chunk-EQCCHGRT.js";
@@ -10,6 +7,9 @@ import {
import {
require_react
} from "./chunk-WUR7D6NS.js";
import {
clsx_default
} from "./chunk-U7P2NEEE.js";
import {
__commonJS,
__export,

View File

@@ -5,7 +5,7 @@ import {
Zap, TrendingUp, Eye, MousePointerClick, DollarSign,
Upload, Play, Image, Film, RefreshCw, ArrowRight, Plus, X,
AlertTriangle, ArrowRightLeft, PlusCircle, SlidersHorizontal,
Activity, Check, Link2,
Activity, Check, Link2, WandSparkles,
type LucideIcon,
} from 'lucide-react';
import {
@@ -17,6 +17,7 @@ import { useCurrency } from '@/store/useCurrencyStore';
import type { Campaign, MarketingAsset, LiveOptimizationEvent, LiveEventType } from '@/types';
import { GroundTruthPicker } from './GroundTruthPicker';
import { CatalystMarketingTab } from './CatalystMarketingTab';
import { CatalystDreamWeaverTab } from './CatalystDreamWeaverTab';
import type { GroundTruthSelection } from './GroundTruthPicker';
// ── Design tokens ─────────────────────────────────────────────────────────────
@@ -917,7 +918,7 @@ function WarRoom() {
// Tab Bar
// ─────────────────────────────────────────────────────────────────────────────
type TabId = 'studio' | 'command' | 'intelligence' | 'war-room' | 'marketing';
type TabId = 'studio' | 'command' | 'intelligence' | 'war-room' | 'marketing' | 'dream-weaver';
const TABS: Array<{ id: TabId; label: string; icon: LucideIcon }> = [
{ id: 'studio', label: 'The Studio', icon: Clapperboard },
@@ -925,6 +926,7 @@ const TABS: Array<{ id: TabId; label: string; icon: LucideIcon }> = [
{ id: 'intelligence', label: 'Intelligence & ROI', icon: BarChart3 },
{ id: 'war-room', label: 'War Room', icon: Globe },
{ id: 'marketing', label: 'Marketing', icon: TrendingUp },
{ id: 'dream-weaver', label: 'Dream Weaver', icon: WandSparkles },
];
// ─────────────────────────────────────────────────────────────────────────────
@@ -940,6 +942,7 @@ export function Catalyst() {
'intelligence': <IntelligenceROI />,
'war-room': <WarRoom />,
'marketing': <CatalystMarketingTab />,
'dream-weaver': <CatalystDreamWeaverTab />,
};
return (
@@ -998,8 +1001,8 @@ export function Catalyst() {
</motion.div>
</AnimatePresence>
{/* Live Optimization Feed — always visible */}
<LiveOptimizationFeed />
{/* Live Optimization Feed — hidden on Dream Weaver because generation has its own status surface. */}
{activeTab !== 'dream-weaver' && <LiveOptimizationFeed />}
</section>
);
}

View File

@@ -0,0 +1,524 @@
import { useEffect, useRef, useState, type ChangeEvent, type CSSProperties } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
AlertTriangle,
Check,
Download,
ExternalLink,
Home,
Image as ImageIcon,
Loader2,
RefreshCw,
Server,
Sparkles,
Upload,
WandSparkles,
} from 'lucide-react';
import { useMarketingStore } from '@/store/useMarketingStore';
import {
DREAM_WEAVER_URL,
checkDreamWeaverHealth,
fetchDreamWeaverResult,
getDreamWeaverStatus,
submitDreamWeaverJob,
type DreamWeaverHealth,
type DreamWeaverJobResponse,
type DreamWeaverStatusResponse,
} from '@/lib/dreamWeaverApi';
const GLASS: CSSProperties = {
background: 'rgba(8, 10, 18, 0.82)',
border: '1px solid rgba(59,130,246,0.14)',
backdropFilter: 'blur(24px)',
WebkitBackdropFilter: 'blur(24px)',
boxShadow: '0 0 0 1px rgba(255,255,255,0.04), 0 4px 32px rgba(0,0,0,0.55)',
};
const INNER: CSSProperties = {
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.07)',
};
const ROOM_TYPES = [
{ id: 'bedroom', label: 'Bedroom' },
{ id: 'living_room', label: 'Living Room' },
{ id: 'bathroom', label: 'Bathroom' },
{ id: 'kitchen', label: 'Kitchen' },
{ id: 'dining_room', label: 'Dining Room' },
{ id: 'home_office', label: 'Office' },
{ id: 'hallway', label: 'Hallway' },
{ id: 'balcony', label: 'Balcony' },
] as const;
type ProcessingState = 'idle' | 'checking' | 'submitting' | 'rendering' | 'downloading';
interface DreamWeaverOutput {
id: string;
roomLabel: string;
keywords: string;
imageUrl: string;
createdAt: Date;
}
function sleep(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function isReadyStatus(status: DreamWeaverStatusResponse) {
const normalized = status.status?.toLowerCase() ?? '';
return Boolean(status.ready) || ['ready', 'completed', 'complete', 'succeeded', 'success', 'finished'].includes(normalized);
}
function isFailedStatus(status: DreamWeaverStatusResponse) {
const normalized = status.status?.toLowerCase() ?? '';
return ['failed', 'error', 'cancelled', 'canceled'].includes(normalized);
}
function statusLabel(state: ProcessingState, health: DreamWeaverHealth | null) {
if (state === 'checking') return 'Checking gateway';
if (state === 'submitting') return 'Submitting render';
if (state === 'rendering') return 'ComfyUI rendering';
if (state === 'downloading') return 'Fetching result';
if (!health) return 'Gateway unknown';
if (health.online && health.routeMounted && health.comfyuiOnline === false) return 'Gateway online · ComfyUI offline';
if (health.online && health.routeMounted && health.comfyuiOnline && health.checkpointReady === false) return 'Gateway online · Model missing';
return health.online && health.routeMounted ? 'Gateway online' : 'Gateway offline';
}
function ResultActions({ output }: { output: DreamWeaverOutput }) {
function downloadResult() {
const anchor = document.createElement('a');
anchor.href = output.imageUrl;
anchor.download = `dream-weaver-${output.id}.png`;
anchor.click();
}
return (
<div className="flex items-center gap-2">
<button
type="button"
onClick={downloadResult}
className="h-9 w-9 rounded-xl flex items-center justify-center transition-colors hover:bg-white/10"
style={INNER}
title="Download generated image"
>
<Download className="w-4 h-4 text-white/75" />
</button>
<button
type="button"
onClick={() => window.open(output.imageUrl, '_blank', 'noopener,noreferrer')}
className="h-9 w-9 rounded-xl flex items-center justify-center transition-colors hover:bg-white/10"
style={INNER}
title="Open generated image"
>
<ExternalLink className="w-4 h-4 text-white/75" />
</button>
</div>
);
}
export function CatalystDreamWeaverTab() {
const { pushLiveEvent } = useMarketingStore();
const fileInputRef = useRef<HTMLInputElement>(null);
const objectUrlsRef = useRef<Set<string>>(new Set());
const [sourceFile, setSourceFile] = useState<File | null>(null);
const [sourcePreview, setSourcePreview] = useState<string | null>(null);
const [selectedRoomType, setSelectedRoomType] = useState<(typeof ROOM_TYPES)[number]['id']>('bedroom');
const [keywords, setKeywords] = useState('');
const [health, setHealth] = useState<DreamWeaverHealth | null>(null);
const [processingState, setProcessingState] = useState<ProcessingState>('checking');
const [progress, setProgress] = useState('Checking Dream Weaver gateway...');
const [error, setError] = useState<string | null>(null);
const [currentOutput, setCurrentOutput] = useState<DreamWeaverOutput | null>(null);
const [history, setHistory] = useState<DreamWeaverOutput[]>([]);
const isProcessing = processingState !== 'idle' && processingState !== 'checking';
const roomLabel = ROOM_TYPES.find((room) => room.id === selectedRoomType)?.label ?? 'Bedroom';
useEffect(() => {
void refreshHealth();
return () => {
objectUrlsRef.current.forEach((url) => URL.revokeObjectURL(url));
objectUrlsRef.current.clear();
};
}, []);
function rememberObjectUrl(blobOrFile: Blob) {
const url = URL.createObjectURL(blobOrFile);
objectUrlsRef.current.add(url);
return url;
}
function setSourceFromFile(file: File) {
if (!file.type.startsWith('image/')) {
setError('Dream Weaver needs a source room image.');
return;
}
const previewUrl = rememberObjectUrl(file);
setSourceFile(file);
setSourcePreview(previewUrl);
setCurrentOutput(null);
setError(null);
}
function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (file) {
setSourceFromFile(file);
}
event.target.value = '';
}
async function refreshHealth() {
setProcessingState('checking');
setProgress('Checking Dream Weaver gateway...');
const nextHealth = await checkDreamWeaverHealth();
setHealth(nextHealth);
setProcessingState('idle');
setProgress(nextHealth.online && nextHealth.routeMounted
? nextHealth.comfyuiOnline === false
? `Gateway is online and the Dream Weaver route is mounted. ComfyUI is offline${nextHealth.comfyuiUrl ? ` at ${nextHealth.comfyuiUrl}` : ''}.`
: nextHealth.checkpointReady === false
? `ComfyUI is online${nextHealth.comfyuiUrl ? ` at ${nextHealth.comfyuiUrl}` : ''}, but no checkpoint model is installed. Hydrate RealVisXL into ComfyUI/models/checkpoints.`
: 'Gateway is online and the Dream Weaver route is mounted.'
: nextHealth.detail ?? 'Dream Weaver gateway is not reachable.');
}
async function pollUntilReady(job: DreamWeaverJobResponse) {
let latestResultUrl = job.result_url;
for (let attempt = 1; attempt <= 150; attempt += 1) {
const status = await getDreamWeaverStatus(job);
latestResultUrl = status.result_url ?? latestResultUrl;
setProgress(status.status ? `Render ${status.status} · poll ${attempt}/150` : `Render queued · poll ${attempt}/150`);
if (isReadyStatus(status)) {
return latestResultUrl;
}
if (isFailedStatus(status) || status.error) {
throw new Error(status.error ?? `Dream Weaver render ${status.status ?? 'failed'}.`);
}
await sleep(2000);
}
throw new Error('Dream Weaver timed out after 5 minutes.');
}
async function generate() {
if (!sourceFile || isProcessing) return;
setError(null);
setCurrentOutput(null);
try {
setProcessingState('submitting');
setProgress(`Submitting ${roomLabel.toLowerCase()} staging request...`);
const job = await submitDreamWeaverJob({
image: sourceFile,
roomType: selectedRoomType,
keywords,
});
setProcessingState('rendering');
setProgress(`Job ${job.job_id} accepted. Waiting for ComfyUI output...`);
const resultUrl = await pollUntilReady(job);
setProcessingState('downloading');
setProgress('Fetching generated image...');
const resultBlob = await fetchDreamWeaverResult(job.job_id, resultUrl);
const imageUrl = rememberObjectUrl(resultBlob);
const output: DreamWeaverOutput = {
id: job.job_id,
roomLabel,
keywords: keywords.trim(),
imageUrl,
createdAt: new Date(),
};
setCurrentOutput(output);
setHistory((items) => [output, ...items].slice(0, 6));
setProgress('Dream Weaver render complete.');
pushLiveEvent({
id: `dw-${job.job_id}-${Date.now()}`,
type: 'create',
campaignName: 'Dream Weaver',
message: `${roomLabel} staging render completed.`,
timestamp: new Date(),
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Dream Weaver render failed.';
setError(message);
setProgress(message);
pushLiveEvent({
id: `dw-error-${Date.now()}`,
type: 'alert',
campaignName: 'Dream Weaver',
message,
timestamp: new Date(),
});
} finally {
setProcessingState('idle');
}
}
return (
<div className="space-y-5">
<motion.div
className="relative rounded-2xl p-5 overflow-hidden"
style={GLASS}
initial={{ opacity: 0, y: 16, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.35, ease: [0.4, 0, 0.2, 1] }}
>
<div className="absolute inset-x-0 top-0 h-px pointer-events-none"
style={{ background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.10), transparent)' }} />
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h3 className="text-sm font-semibold text-white mb-0.5 flex items-center gap-2">
<WandSparkles className="w-4 h-4 text-blue-400" /> Dream Weaver
</h3>
<p className="text-xs" style={{ color: 'rgba(148,163,184,0.55)' }}>
Room image transformation pipeline using the same Dream Weaver gateway as the iPad app.
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 rounded-xl px-3 py-2 text-xs" style={INNER}>
<span className={`h-2 w-2 rounded-full ${health?.online && health.routeMounted ? 'bg-green-400' : 'bg-red-400'}`} />
<span className="text-white/75">{statusLabel(processingState, health)}</span>
</div>
<button
type="button"
onClick={() => void refreshHealth()}
className="h-9 w-9 rounded-xl flex items-center justify-center transition-colors hover:bg-white/10"
style={INNER}
title="Check Dream Weaver gateway"
>
<RefreshCw className={`w-4 h-4 text-white/75 ${processingState === 'checking' ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
</motion.div>
<div className="grid grid-cols-1 xl:grid-cols-[0.95fr_1.05fr] gap-5">
<motion.div
className="relative rounded-2xl p-5 overflow-hidden"
style={GLASS}
initial={{ opacity: 0, y: 16, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.35, delay: 0.05, ease: [0.4, 0, 0.2, 1] }}
>
<div className="space-y-5">
<div>
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<ImageIcon className="w-4 h-4 text-blue-400" /> Source Room
</h3>
<p className="text-xs mt-1" style={{ color: 'rgba(148,163,184,0.55)' }}>
Upload a ground-truth room photograph, choose the target room type, then add optional styling keywords.
</p>
</div>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
const file = event.dataTransfer.files?.[0];
if (file) setSourceFromFile(file);
}}
className="relative w-full min-h-[300px] rounded-2xl overflow-hidden flex items-center justify-center text-left transition-colors hover:border-blue-400/40"
style={{ ...INNER, background: sourcePreview ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.025)' }}
>
{sourcePreview ? (
<img src={sourcePreview} alt="Selected source room" className="absolute inset-0 h-full w-full object-cover" />
) : (
<div className="flex flex-col items-center gap-3 text-center px-6">
<div className="h-12 w-12 rounded-2xl flex items-center justify-center" style={INNER}>
<Upload className="h-5 w-5 text-blue-400" />
</div>
<div>
<p className="text-sm font-semibold text-white">Upload room image</p>
<p className="text-xs mt-1" style={{ color: 'rgba(148,163,184,0.55)' }}>
Click to browse or drop a photo here.
</p>
</div>
</div>
)}
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFileChange} />
</button>
<div>
<p className="text-xs font-medium uppercase tracking-widest mb-2" style={{ color: 'rgba(148,163,184,0.65)' }}>
Room type
</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{ROOM_TYPES.map((room) => {
const selected = selectedRoomType === room.id;
return (
<button
key={room.id}
type="button"
onClick={() => setSelectedRoomType(room.id)}
className="rounded-xl px-3 py-2 text-sm font-medium flex items-center gap-2 transition-colors"
style={{
background: selected ? 'rgba(59,130,246,0.18)' : 'rgba(255,255,255,0.04)',
border: selected ? '1px solid rgba(59,130,246,0.38)' : '1px solid rgba(255,255,255,0.07)',
color: selected ? '#fff' : 'rgba(226,232,240,0.72)',
}}
>
<Home className="w-3.5 h-3.5 text-blue-400" />
<span>{room.label}</span>
</button>
);
})}
</div>
</div>
<div>
<label className="text-xs font-medium uppercase tracking-widest mb-2 block" style={{ color: 'rgba(148,163,184,0.65)' }}>
Keywords
</label>
<textarea
value={keywords}
onChange={(event) => setKeywords(event.target.value)}
placeholder="gold, marble, luxury, soft daylight"
rows={3}
className="w-full resize-none rounded-xl px-3 py-2 text-sm text-white placeholder-white/20 outline-none focus:border-blue-400/50"
style={INNER}
/>
</div>
<AnimatePresence>
{error && (
<motion.div
className="rounded-xl p-3 flex items-start gap-2"
style={{ background: 'rgba(239,68,68,0.12)', border: '1px solid rgba(239,68,68,0.25)' }}
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
>
<AlertTriangle className="w-4 h-4 text-red-400 mt-0.5 flex-shrink-0" />
<p className="text-sm text-red-200 leading-relaxed">{error}</p>
</motion.div>
)}
</AnimatePresence>
<div className="flex items-center justify-between gap-3 rounded-2xl p-3" style={INNER}>
<div className="min-w-0">
<p className="text-xs font-medium text-white truncate">{progress}</p>
<p className="text-[11px] mt-1 truncate" style={{ color: 'rgba(148,163,184,0.5)' }}>
Gateway: {DREAM_WEAVER_URL}
</p>
</div>
<button
type="button"
onClick={() => void generate()}
disabled={!sourceFile || isProcessing || health?.routeMounted === false || health?.comfyuiOnline === false || health?.checkpointReady === false}
className="h-11 px-5 rounded-xl text-sm font-semibold flex items-center justify-center gap-2 disabled:opacity-45 disabled:cursor-not-allowed transition-colors"
style={{ background: 'hsl(var(--accent))', color: 'hsl(var(--accent-fg))' }}
>
{isProcessing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
Generate
</button>
</div>
</div>
</motion.div>
<motion.div
className="relative rounded-2xl p-5 overflow-hidden"
style={GLASS}
initial={{ opacity: 0, y: 16, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.35, delay: 0.1, ease: [0.4, 0, 0.2, 1] }}
>
<div className="flex items-center justify-between gap-3 mb-4">
<div>
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<WandSparkles className="w-4 h-4 text-blue-400" /> Generated Staging
</h3>
<p className="text-xs mt-1" style={{ color: 'rgba(148,163,184,0.55)' }}>
The result appears here as soon as the gateway marks the job ready.
</p>
</div>
{currentOutput && <ResultActions output={currentOutput} />}
</div>
<div className="relative min-h-[460px] rounded-2xl overflow-hidden flex items-center justify-center" style={INNER}>
{currentOutput ? (
<img src={currentOutput.imageUrl} alt={`${currentOutput.roomLabel} Dream Weaver result`} className="absolute inset-0 h-full w-full object-contain bg-black" />
) : (
<div className="flex flex-col items-center gap-3 text-center px-8">
<div className="h-14 w-14 rounded-2xl flex items-center justify-center" style={INNER}>
{isProcessing ? <Loader2 className="h-6 w-6 text-blue-400 animate-spin" /> : <WandSparkles className="h-6 w-6 text-blue-400" />}
</div>
<div>
<p className="text-sm font-semibold text-white">{isProcessing ? 'Dream Weaver is rendering' : 'No generated image yet'}</p>
<p className="text-xs mt-1 max-w-md" style={{ color: 'rgba(148,163,184,0.55)' }}>
Upload a source image and generate a staging render to populate this canvas.
</p>
</div>
</div>
)}
</div>
{history.length > 0 && (
<div className="mt-5">
<div className="flex items-center justify-between mb-3">
<p className="text-xs font-medium uppercase tracking-widest" style={{ color: 'rgba(148,163,184,0.65)' }}>
Recent renders
</p>
<span className="text-[11px]" style={{ color: 'rgba(148,163,184,0.45)' }}>{history.length}/6</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{history.map((item) => (
<button
key={item.id}
type="button"
onClick={() => setCurrentOutput(item)}
className="group rounded-xl overflow-hidden text-left transition-colors hover:border-blue-400/40"
style={INNER}
>
<div className="aspect-[4/3] bg-black overflow-hidden">
<img src={item.imageUrl} alt={item.roomLabel} className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-300" />
</div>
<div className="p-3">
<div className="flex items-center gap-1.5 text-xs font-semibold text-white">
<Check className="w-3.5 h-3.5 text-green-400" />
{item.roomLabel}
</div>
<p className="text-[11px] mt-1 truncate" style={{ color: 'rgba(148,163,184,0.55)' }}>
{item.keywords || item.createdAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p>
</div>
</button>
))}
</div>
</div>
)}
</motion.div>
</div>
<motion.div
className="relative rounded-2xl p-5 overflow-hidden"
style={GLASS}
initial={{ opacity: 0, y: 16, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.35, delay: 0.15, ease: [0.4, 0, 0.2, 1] }}
>
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<Server className="w-4 h-4 text-blue-400" /> Gateway Contract
</h3>
<span className="text-xs font-medium" style={{ color: health?.routeMounted ? '#4ade80' : '#f87171' }}>
{health?.routeMounted ? 'Route mounted' : 'Route not verified'}
</span>
</div>
<div className="mt-4 grid grid-cols-1 md:grid-cols-3 gap-3">
{['POST /dream-weaver', 'GET /dream-weaver/status/{job_id}', 'GET /dream-weaver/result/{job_id}'].map((endpoint) => (
<div key={endpoint} className="rounded-xl p-3" style={INNER}>
<p className="text-xs font-mono text-blue-200">{endpoint}</p>
</div>
))}
</div>
</motion.div>
</div>
);
}

View File

@@ -1,3 +1,5 @@
import { buildVelocityHeaders } from '@/lib/velocitySession';
const rawApiBase = import.meta.env.VITE_API_URL?.trim();
const DEPLOYED_BACKEND_ORIGIN = 'https://velocity.desineuron.in';
@@ -75,10 +77,17 @@ export interface MarketingCampaignSummary {
async function requestJson<T>(path: string): Promise<T> {
const response = await fetch(`${API_URL}${path}`, {
headers: { Accept: 'application/json' },
headers: buildVelocityHeaders(undefined, false),
});
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
const body = await response.json().catch(() => ({}));
throw new Error(
typeof body?.detail === 'string'
? body.detail
: typeof body?.message === 'string'
? body.message
: `Request failed: ${response.status}`,
);
}
return response.json() as Promise<T>;
}

View File

@@ -8,19 +8,19 @@ import type {
Client360Snapshot,
CrmOpportunityCard,
CrmTask,
CrmLeadStageUpdate,
KanbanColumn,
ImportBatchSummary,
ImportProposal,
ImportReviewDecision,
QdScoreEntry,
} from '@/types/crmTypes';
import { VELOCITY_TOKEN_KEY } from '@/lib/velocityPlatformClient';
import { buildVelocityHeaders } from '@/lib/velocitySession';
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? '';
function getAuthHeaders(): Record<string, string> {
const token = localStorage.getItem(VELOCITY_TOKEN_KEY);
return token ? { Authorization: `Bearer ${token}` } : {};
return Object.fromEntries(buildVelocityHeaders(undefined, false).entries());
}
async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
@@ -87,6 +87,23 @@ export async function fetchOpportunities(params?: {
return res.data;
}
export async function updateOpportunity(body: {
opportunity_id: string;
stage?: string;
value?: number | null;
probability?: number | null;
expected_close_date?: string | null;
next_action?: string | null;
notes?: string | null;
}): Promise<CrmOpportunityCard> {
const { opportunity_id, ...payload } = body;
const res = await apiFetch<{ status: string; data: CrmOpportunityCard }>(`/api/crm/opportunities/${opportunity_id}`, {
method: 'PATCH',
body: JSON.stringify(payload),
});
return res.data;
}
// ── Tasks ─────────────────────────────────────────────────────────────────────
export async function fetchTasks(params?: {
@@ -118,6 +135,23 @@ export async function createTask(body: {
return res.data;
}
export async function updateTask(body: {
reminder_id: string;
status: 'pending' | 'done' | 'snoozed' | 'cancelled';
due_at?: string;
notes?: string;
}): Promise<CrmTask> {
const res = await apiFetch<{ status: string; data: CrmTask }>(`/api/crm/tasks/${body.reminder_id}`, {
method: 'PATCH',
body: JSON.stringify({
status: body.status,
due_at: body.due_at,
notes: body.notes,
}),
});
return res.data;
}
// ── Kanban ────────────────────────────────────────────────────────────────────
export async function fetchKanbanBoard(): Promise<KanbanColumn[]> {
@@ -125,6 +159,21 @@ export async function fetchKanbanBoard(): Promise<KanbanColumn[]> {
return res.data;
}
export async function updateLeadStage(body: {
lead_id: string;
status: string;
notes?: string;
}): Promise<CrmLeadStageUpdate> {
const res = await apiFetch<{ status: string; data: CrmLeadStageUpdate }>(`/api/crm/leads/${body.lead_id}/stage`, {
method: 'PATCH',
body: JSON.stringify({
status: body.status,
notes: body.notes,
}),
});
return res.data;
}
// ── QD Scores ─────────────────────────────────────────────────────────────────
export async function fetchQdScore(personId: string): Promise<{

View File

@@ -0,0 +1,197 @@
import { API_URL } from '@/lib/api';
import { buildVelocityHeaders } from '@/lib/velocitySession';
const rawDreamWeaverBase = import.meta.env.VITE_DREAM_WEAVER_URL?.trim();
const rawDreamWeaverApiKey = import.meta.env.VITE_DREAM_WEAVER_API_KEY?.trim();
const LOCAL_DREAM_WEAVER_GATEWAY = 'http://127.0.0.1:8082';
export const DREAM_WEAVER_URL = (rawDreamWeaverBase && rawDreamWeaverBase.length > 0
? rawDreamWeaverBase
: import.meta.env.DEV
? LOCAL_DREAM_WEAVER_GATEWAY
: API_URL
).replace(/\/$/, '');
export interface DreamWeaverHealth {
online: boolean;
routeMounted: boolean;
status: string;
comfyuiOnline?: boolean;
comfyuiUrl?: string;
checkpointReady?: boolean;
checkpointCount?: number;
availableCheckpoints?: string[];
preferredCheckpoints?: string[];
detail?: string;
}
export interface DreamWeaverJobResponse {
job_id: string;
status?: string;
poll_url?: string;
result_url?: string;
}
export interface DreamWeaverStatusResponse {
status?: string;
ready?: boolean;
result_url?: string;
error?: string;
}
export interface SubmitDreamWeaverJobInput {
image: File;
roomType: string;
keywords: string;
}
function buildDreamWeaverHeaders(init?: HeadersInit): Headers {
const headers = buildVelocityHeaders(init, false);
if (rawDreamWeaverApiKey && !headers.has('X-Dream-Weaver-API-Key')) {
headers.set('X-Dream-Weaver-API-Key', rawDreamWeaverApiKey);
}
return headers;
}
function resolveDreamWeaverUrl(candidate: string | undefined, fallbackPath: string): string {
const path = candidate && candidate.trim().length > 0 ? candidate.trim() : fallbackPath;
if (/^https?:\/\//i.test(path)) {
return path;
}
return `${DREAM_WEAVER_URL}${path.startsWith('/') ? path : `/${path}`}`;
}
async function readErrorMessage(response: Response, fallback: string): Promise<string> {
const body = await response.json().catch(() => null) as { detail?: unknown; message?: unknown; error?: unknown } | null;
if (typeof body?.detail === 'string') return body.detail;
if (typeof body?.message === 'string') return body.message;
if (typeof body?.error === 'string') return body.error;
const text = await response.text().catch(() => '');
return text.trim() || fallback;
}
async function requestDreamWeaverJson<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, {
...init,
headers: buildDreamWeaverHeaders(init?.headers),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, `Dream Weaver request failed: ${response.status}`));
}
return response.json() as Promise<T>;
}
export async function checkDreamWeaverHealth(): Promise<DreamWeaverHealth> {
let status = 'offline';
let detail: string | undefined;
let comfyuiOnline: boolean | undefined;
let comfyuiUrl: string | undefined;
let checkpointReady: boolean | undefined;
let checkpointCount: number | undefined;
let availableCheckpoints: string[] | undefined;
let preferredCheckpoints: string[] | undefined;
let healthOk = false;
try {
const response = await fetch(resolveDreamWeaverUrl(undefined, '/health'), {
headers: buildDreamWeaverHeaders(),
});
const body = await response.json().catch(() => null) as {
status?: unknown;
detail?: unknown;
comfyui?: unknown;
comfyui_url?: unknown;
comfyuiUrl?: unknown;
checkpoint_ready?: unknown;
checkpoint_count?: unknown;
available_checkpoints?: unknown;
preferred_checkpoints?: unknown;
} | null;
status = typeof body?.status === 'string' ? body.status : response.ok ? 'ok' : `HTTP ${response.status}`;
detail = typeof body?.detail === 'string' ? body.detail : undefined;
comfyuiOnline = typeof body?.comfyui === 'boolean' ? body.comfyui : undefined;
comfyuiUrl = typeof body?.comfyui_url === 'string'
? body.comfyui_url
: typeof body?.comfyuiUrl === 'string'
? body.comfyuiUrl
: undefined;
checkpointReady = typeof body?.checkpoint_ready === 'boolean' ? body.checkpoint_ready : undefined;
checkpointCount = typeof body?.checkpoint_count === 'number' ? body.checkpoint_count : undefined;
availableCheckpoints = Array.isArray(body?.available_checkpoints)
? body.available_checkpoints.filter((item): item is string => typeof item === 'string')
: undefined;
preferredCheckpoints = Array.isArray(body?.preferred_checkpoints)
? body.preferred_checkpoints.filter((item): item is string => typeof item === 'string')
: undefined;
healthOk = response.ok && ['ok', 'healthy', 'online'].includes(status.toLowerCase());
} catch (error) {
detail = error instanceof Error ? error.message : 'Unable to reach Dream Weaver gateway.';
}
try {
const probe = await fetch(resolveDreamWeaverUrl(undefined, '/dream-weaver/status/velocity-route-probe'), {
headers: buildDreamWeaverHeaders(),
});
if (probe.ok) {
return { online: healthOk, routeMounted: true, status, comfyuiOnline, comfyuiUrl, checkpointReady, checkpointCount, availableCheckpoints, preferredCheckpoints, detail };
}
const probeMessage = await readErrorMessage(probe, '');
const expectedMissingJob = probe.status === 404 && /job|not found|missing/i.test(probeMessage);
return {
online: healthOk && expectedMissingJob,
routeMounted: expectedMissingJob,
status,
comfyuiOnline,
comfyuiUrl,
checkpointReady,
checkpointCount,
availableCheckpoints,
preferredCheckpoints,
detail: detail ?? probeMessage,
};
} catch (error) {
return {
online: false,
routeMounted: false,
status,
comfyuiOnline,
comfyuiUrl,
checkpointReady,
checkpointCount,
availableCheckpoints,
preferredCheckpoints,
detail: error instanceof Error ? error.message : detail,
};
}
}
export async function submitDreamWeaverJob(input: SubmitDreamWeaverJobInput): Promise<DreamWeaverJobResponse> {
const formData = new FormData();
formData.append('image', input.image, input.image.name || 'room-source.jpg');
formData.append('room_type', input.roomType);
const trimmedKeywords = input.keywords.trim();
if (trimmedKeywords.length > 0) {
formData.append('keywords', trimmedKeywords);
}
return requestDreamWeaverJson<DreamWeaverJobResponse>(resolveDreamWeaverUrl(undefined, '/dream-weaver'), {
method: 'POST',
body: formData,
});
}
export async function getDreamWeaverStatus(job: Pick<DreamWeaverJobResponse, 'job_id' | 'poll_url'>): Promise<DreamWeaverStatusResponse> {
return requestDreamWeaverJson<DreamWeaverStatusResponse>(
resolveDreamWeaverUrl(job.poll_url, `/dream-weaver/status/${encodeURIComponent(job.job_id)}`),
);
}
export async function fetchDreamWeaverResult(jobId: string, resultUrl?: string): Promise<Blob> {
const response = await fetch(resolveDreamWeaverUrl(resultUrl, `/dream-weaver/result/${encodeURIComponent(jobId)}`), {
headers: buildDreamWeaverHeaders({ Accept: 'image/png,image/*,*/*' }),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, `Dream Weaver result failed: ${response.status}`));
}
return response.blob();
}

View File

@@ -1,10 +1,19 @@
import { API_URL } from '@/lib/api';
export const VELOCITY_TOKEN_KEY = 'velocity-api-token';
import {
buildVelocityHeaders,
setVelocityToken,
} from '@/lib/velocitySession';
export {
VELOCITY_TOKEN_KEY,
clearVelocityToken,
getVelocityToken,
setVelocityToken,
} from '@/lib/velocitySession';
export interface VelocityUserProfile {
user_id: string;
role: string;
tenant_id?: string;
full_name?: string | null;
email?: string | null;
avatar_url?: string | null;
@@ -13,6 +22,7 @@ export interface VelocityUserProfile {
export interface VelocityActiveUser {
user_id: string;
role: string;
tenant_id?: string;
full_name?: string | null;
email?: string | null;
avatar_url?: string | null;
@@ -148,18 +158,7 @@ export interface InventoryPropertySummary {
}
function buildHeaders(init?: HeadersInit, includeJson = true): Headers {
const headers = new Headers(init);
if (includeJson && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
if (!headers.has('Accept')) {
headers.set('Accept', 'application/json');
}
const token = getVelocityToken();
if (token && !headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${token}`);
}
return headers;
return buildVelocityHeaders(init, includeJson);
}
async function platformFetch<T>(path: string, init?: RequestInit): Promise<T> {
@@ -182,18 +181,6 @@ async function platformFetch<T>(path: string, init?: RequestInit): Promise<T> {
return response.json() as Promise<T>;
}
export function setVelocityToken(token: string) {
localStorage.setItem(VELOCITY_TOKEN_KEY, token);
}
export function getVelocityToken(): string | null {
return localStorage.getItem(VELOCITY_TOKEN_KEY);
}
export function clearVelocityToken() {
localStorage.removeItem(VELOCITY_TOKEN_KEY);
}
export function normalizeVelocityRole(role: string | null | undefined): string {
return (role ?? '').trim().toUpperCase();
}

View File

@@ -0,0 +1,37 @@
export const VELOCITY_TOKEN_KEY = 'velocity-api-token';
export function getVelocityToken(): string | null {
if (typeof window === 'undefined') {
return null;
}
return window.localStorage.getItem(VELOCITY_TOKEN_KEY);
}
export function setVelocityToken(token: string) {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(VELOCITY_TOKEN_KEY, token);
}
export function clearVelocityToken() {
if (typeof window === 'undefined') {
return;
}
window.localStorage.removeItem(VELOCITY_TOKEN_KEY);
}
export function buildVelocityHeaders(init?: HeadersInit, includeJson = true): Headers {
const headers = new Headers(init);
if (includeJson && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
if (!headers.has('Accept')) {
headers.set('Accept', 'application/json');
}
const token = getVelocityToken();
if (token && !headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${token}`);
}
return headers;
}

View File

@@ -17,7 +17,7 @@ import type {
OracleEnvelope,
CanvasPageRevision,
} from '../types/canvas';
import { VELOCITY_TOKEN_KEY } from '@/lib/velocityPlatformClient';
import { VELOCITY_TOKEN_KEY } from '@/lib/velocitySession';
function getBrowserOrigin(): string {
return typeof window !== 'undefined' ? window.location.origin : '';

View File

@@ -15,7 +15,7 @@ interface MarketingState {
adInsights: AdInsight[];
liveEvents: LiveOptimizationEvent[];
settings: CatalystSettings;
activeTab: 'studio' | 'command' | 'intelligence' | 'war-room' | 'marketing';
activeTab: 'studio' | 'command' | 'intelligence' | 'war-room' | 'marketing' | 'dream-weaver';
// Actions
addCampaign: (campaign: Campaign) => void;

View File

@@ -69,6 +69,7 @@ export interface CrmOpportunityCard {
probability: number | null;
expected_close_date: string | null;
next_action: string | null;
notes?: string | null;
project_id: string | null;
unit_id: string | null;
// When fetched from list endpoint, person-level fields are included
@@ -109,6 +110,16 @@ export interface CrmTask {
client_phone?: string;
}
export interface CrmLeadStageUpdate {
lead_id: string;
person_id: string;
status: CrmLeadStatus;
budget_band: string | null;
urgency: string | null;
client_name?: string;
client_phone?: string;
}
// ── Property Interest ─────────────────────────────────────────────────────────
export interface PropertyInterest {

View File

@@ -25,7 +25,7 @@ from __future__ import annotations
import json
import logging
import uuid
from datetime import UTC, datetime
from datetime import datetime, timezone
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
@@ -131,7 +131,7 @@ async def get_health(
return {
"status": "ok",
"timestamp": datetime.now(UTC).isoformat(),
"timestamp": datetime.now(timezone.utc).isoformat(),
"database": {
"connected": True,
"latency_ms": db_latency_ms,
@@ -191,7 +191,7 @@ async def get_queues(
"synthetic_jobs": {r["status"]: r["count"] for r in synthetic_queue},
"inventory_batches": {r["status"]: r["count"] for r in inventory_queue},
"admin_actions": {r["status"]: r["count"] for r in admin_queue},
"timestamp": datetime.now(UTC).isoformat(),
"timestamp": datetime.now(timezone.utc).isoformat(),
}
@@ -215,7 +215,7 @@ async def get_installs(
)
return {
"installs": [dict(r) for r in rows],
"timestamp": datetime.now(UTC).isoformat(),
"timestamp": datetime.now(timezone.utc).isoformat(),
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -21,7 +21,7 @@ from __future__ import annotations
import json
import logging
from datetime import UTC, datetime
from datetime import datetime, timezone
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
@@ -43,6 +43,10 @@ def _pool(request: Request):
return pool
def _tenant_scope(user) -> str:
return user.tenant_id
# ── Pydantic Models ───────────────────────────────────────────────────────────
VALID_SOURCE_TYPES = {"csv", "json", "api_push", "manual"}
@@ -111,7 +115,7 @@ async def create_import_batch(
VALUES ($1, $2, $3, $4, $5)
RETURNING batch_id, status, created_at
""",
user.role, body.source_type, user.user_id, body.total_rows, body.source_file_ref,
_tenant_scope(user), body.source_type, user.user_id, body.total_rows, body.source_file_ref,
)
return dict(row)
@@ -134,10 +138,10 @@ async def list_import_batches(
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
""",
user.role, limit, offset,
_tenant_scope(user), limit, offset,
)
total = await conn.fetchval(
"SELECT COUNT(*) FROM inventory_import_batches WHERE tenant_id=$1", user.role,
"SELECT COUNT(*) FROM inventory_import_batches WHERE tenant_id=$1", _tenant_scope(user),
)
return {"total": total, "limit": limit, "offset": offset, "batches": [dict(r) for r in rows]}
@@ -154,7 +158,7 @@ async def get_import_batch(
"""
SELECT * FROM inventory_import_batches WHERE batch_id=$1 AND tenant_id=$2
""",
batch_id, user.role,
batch_id, _tenant_scope(user),
)
if not row:
raise HTTPException(404, "Batch not found")
@@ -187,7 +191,7 @@ async def create_property(
)
RETURNING property_id, created_at
""",
user.role, body.batch_id, body.source_id, body.project_name, body.developer_name,
_tenant_scope(user), body.batch_id, body.source_id, body.project_name, body.developer_name,
json.dumps(body.location), body.property_type, json.dumps(body.price_bands),
json.dumps(body.unit_mix), body.amenities,
body.status, json.dumps(body.validation_state),
@@ -207,7 +211,7 @@ async def list_properties(
pool = _pool(request)
async with pool.acquire() as conn:
where_clause = "WHERE tenant_id = $1"
params: list[Any] = [user.role]
params: list[Any] = [_tenant_scope(user)]
idx = 2
if status_filter:
@@ -246,7 +250,7 @@ async def get_property(
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT * FROM inventory_properties WHERE property_id=$1 AND tenant_id=$2",
property_id, user.role,
property_id, _tenant_scope(user),
)
if not row:
raise HTTPException(404, "Property not found")
@@ -287,8 +291,8 @@ async def update_property(
if not updates:
raise HTTPException(400, "No fields to update")
_add("updated_at", datetime.now(UTC))
values.extend([property_id, user.role])
_add("updated_at", datetime.now(timezone.utc))
values.extend([property_id, _tenant_scope(user)])
pool = _pool(request)
async with pool.acquire() as conn:
@@ -319,7 +323,7 @@ async def archive_property(
SET status='archived', updated_at=NOW()
WHERE property_id=$1 AND tenant_id=$2
""",
property_id, user.role,
property_id, _tenant_scope(user),
)
if result == "UPDATE 0":
raise HTTPException(404, "Property not found")
@@ -344,7 +348,7 @@ async def add_media(
# Verify property belongs to tenant
exists = await conn.fetchval(
"SELECT 1 FROM inventory_properties WHERE property_id=$1 AND tenant_id=$2",
property_id, user.role,
property_id, _tenant_scope(user),
)
if not exists:
raise HTTPException(404, "Property not found")
@@ -356,7 +360,7 @@ async def add_media(
VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8)
RETURNING media_asset_id, created_at
""",
property_id, user.role, body.media_type, body.url, body.thumbnail_url,
property_id, _tenant_scope(user), body.media_type, body.url, body.thumbnail_url,
body.sort_order, json.dumps(body.metadata), user.user_id,
)
return {"media_asset_id": str(row["media_asset_id"]), "created_at": str(row["created_at"])}
@@ -377,7 +381,7 @@ async def list_media(
WHERE property_id=$1 AND tenant_id=$2
ORDER BY sort_order ASC, created_at ASC
""",
property_id, user.role,
property_id, _tenant_scope(user),
)
return {"media": [dict(r) for r in rows]}
@@ -392,7 +396,7 @@ async def delete_media(
async with pool.acquire() as conn:
result = await conn.execute(
"DELETE FROM inventory_media_assets WHERE media_asset_id=$1 AND tenant_id=$2",
media_asset_id, user.role,
media_asset_id, _tenant_scope(user),
)
if result == "DELETE 0":
raise HTTPException(404, "Media asset not found")

View File

@@ -24,7 +24,7 @@ from __future__ import annotations
import logging
import uuid
from datetime import UTC, datetime
from datetime import datetime, timezone
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
@@ -47,7 +47,11 @@ def _pool(request: Request):
def _now() -> str:
return datetime.now(UTC).isoformat()
return datetime.now(timezone.utc).isoformat()
def _tenant_scope(user) -> str:
return user.tenant_id
# ── Pydantic models ───────────────────────────────────────────────────────────
@@ -63,6 +67,8 @@ VALID_DIRECTIONS = {"inbound", "outbound"}
VALID_CONSENT = {"unknown", "granted", "denied", "not_required"}
VALID_CALENDAR_STATUSES = {"tentative", "confirmed", "done", "cancelled"}
class CommunicationEventCreate(BaseModel):
lead_id: str
@@ -102,6 +108,7 @@ class CalendarEventCreate(BaseModel):
start_at: str # ISO8601
end_at: str # ISO8601
all_day: bool = False
status: str = "confirmed"
reminder_minutes: list[int] = Field(default_factory=lambda: [15])
location: Optional[str] = None
metadata: dict = Field(default_factory=dict)
@@ -151,12 +158,12 @@ async def list_events(
ORDER BY timestamp DESC
LIMIT $3 OFFSET $4
""",
user.role, # tenant_id derived from role scope; production uses dedicated tenant field
_tenant_scope(user),
lead_id, limit, offset,
)
total = await conn.fetchval(
"SELECT COUNT(*) FROM edge_communication_events WHERE tenant_id = $1 AND lead_id = $2",
user.role, lead_id,
_tenant_scope(user), lead_id,
)
return {
"total": total,
@@ -197,7 +204,7 @@ async def create_event(
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::jsonb)
RETURNING event_id, created_at
""",
user.role, body.lead_id, body.channel, body.direction, body.provider,
_tenant_scope(user), body.lead_id, body.channel, body.direction, body.provider,
body.capture_mode, body.consent_state, body.duration_seconds,
body.summary, body.raw_reference, body.recording_ref,
json.dumps(body.provider_metadata),
@@ -228,11 +235,11 @@ async def list_memory_facts(
ORDER BY created_at DESC
LIMIT $3 OFFSET $4
""",
user.role, lead_id, limit, offset,
_tenant_scope(user), lead_id, limit, offset,
)
total = await conn.fetchval(
"SELECT COUNT(*) FROM edge_communication_memory_facts WHERE tenant_id=$1 AND lead_id=$2",
user.role, lead_id,
_tenant_scope(user), lead_id,
)
return {"total": total, "limit": limit, "offset": offset, "facts": [dict(r) for r in rows]}
@@ -265,7 +272,7 @@ async def create_import(
) VALUES ($1,$2,$3,'inbound',$4,$5,$6,$7)
RETURNING event_id, created_at
""",
user.role, body.lead_id, body.channel, body.capture_mode,
_tenant_scope(user), body.lead_id, body.channel, body.capture_mode,
body.consent_state, body.recording_ref, body.summary,
)
event_id = event_row["event_id"]
@@ -279,7 +286,7 @@ async def create_import(
) VALUES ($1,$2,'audio',$3)
RETURNING transcription_job_id
""",
user.role, event_id, body.consent_state,
_tenant_scope(user), event_id, body.consent_state,
)
job_id = str(job_row["transcription_job_id"])
@@ -313,7 +320,7 @@ async def create_note(
) VALUES ($1,$2,$3,$4,$5,'operator_note',1.0, TRUE)
RETURNING fact_id, created_at
""",
user.role, body.lead_id, body.fact_type, body.note_text,
_tenant_scope(user), body.lead_id, body.fact_type, body.note_text,
body.effective_date,
)
return {"fact_id": str(row["fact_id"]), "created_at": str(row["created_at"])}
@@ -338,10 +345,11 @@ async def list_calendar_events(
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'
AND start_at >= $3::timestamptz AND end_at <= $4::timestamptz
ORDER BY start_at ASC LIMIT $5
""",
user.role, user.user_id, from_date, to_date, limit,
_tenant_scope(user), user.user_id, from_date, to_date, limit,
)
else:
rows = await conn.fetch(
@@ -350,9 +358,10 @@ async def list_calendar_events(
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
""",
user.role, user.user_id, limit,
_tenant_scope(user), user.user_id, limit,
)
return {"events": [dict(r) for r in rows]}
@@ -365,21 +374,33 @@ async def create_calendar_event(
):
pool = _pool(request)
import json
if body.status not in VALID_CALENDAR_STATUSES:
raise HTTPException(status_code=422, detail="Unsupported calendar status.")
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO user_calendar_events (
tenant_id, owner_user_id, lead_id, source_event_id, title, description,
start_at, end_at, all_day, reminder_minutes, created_by, location, metadata
) VALUES ($1,$2,$3,$4,$5,$6,$7::timestamptz,$8::timestamptz,$9,$10,$11,$12,$13::jsonb)
RETURNING calendar_event_id, created_at
start_at, end_at, all_day, status, reminder_minutes, created_by, location, metadata
) VALUES (
$1::text,$2::text,$3::text,$4::uuid,$5::text,$6::text,
$7::timestamptz,$8::timestamptz,$9::boolean,$10::text,
$11::integer[],$12::text,$13::text,$14::jsonb
)
RETURNING calendar_event_id, lead_id, title, description, start_at, end_at,
all_day, status, reminder_minutes, created_by, location, metadata, created_at
""",
user.role, user.user_id, body.lead_id, body.source_event_id,
_tenant_scope(user), user.user_id, body.lead_id, body.source_event_id,
body.title, body.description, body.start_at, body.end_at,
body.all_day, body.reminder_minutes, "user",
body.all_day, body.status, body.reminder_minutes, "user",
body.location, json.dumps(body.metadata),
)
return {"calendar_event_id": str(row["calendar_event_id"]), "created_at": str(row["created_at"])}
event = dict(row)
event["calendar_event_id"] = str(event["calendar_event_id"])
for key in ("start_at", "end_at", "created_at"):
if event.get(key) is not None and hasattr(event[key], "isoformat"):
event[key] = event[key].isoformat()
return {"status": "ok", "event": event}
@router.patch("/calendar/{calendar_event_id}", summary="Update a calendar event")
@@ -405,15 +426,18 @@ async def update_calendar_event(
if body.description is not None: _add("description", body.description)
if body.start_at is not None: _add("start_at", body.start_at)
if body.end_at is not None: _add("end_at", body.end_at)
if body.status is not None: _add("status", body.status)
if body.status is not None:
if body.status not in VALID_CALENDAR_STATUSES:
raise HTTPException(status_code=422, detail="Unsupported calendar status.")
_add("status", body.status)
if body.reminder_minutes is not None: _add("reminder_minutes", body.reminder_minutes)
if body.location is not None: _add("location", body.location)
if not updates:
raise HTTPException(400, "No fields to update")
_add("updated_at", datetime.now(UTC))
_add("tenant_id", user.role)
_add("updated_at", datetime.now(timezone.utc))
_add("tenant_id", _tenant_scope(user))
_add("owner_user_id", user.user_id)
values.append(calendar_event_id)
@@ -428,7 +452,7 @@ async def update_calendar_event(
)
if result == "UPDATE 0":
raise HTTPException(404, "Calendar event not found or not owned by you")
return {"status": "updated"}
return {"status": "updated", "calendar_event_id": calendar_event_id}
@router.delete("/calendar/{calendar_event_id}", summary="Cancel a calendar event")
@@ -445,7 +469,7 @@ async def delete_calendar_event(
SET status='cancelled', updated_at=NOW()
WHERE tenant_id=$1 AND owner_user_id=$2 AND calendar_event_id=$3
""",
user.role, user.user_id, calendar_event_id,
_tenant_scope(user), user.user_id, calendar_event_id,
)
if result == "UPDATE 0":
raise HTTPException(404, "Calendar event not found or not owned by you")
@@ -471,7 +495,7 @@ async def get_transcript(
WHERE j.event_id = $1 AND e.tenant_id = $2
ORDER BY j.created_at DESC LIMIT 1
""",
event_id, user.role,
event_id, _tenant_scope(user),
)
if not job:
raise HTTPException(404, "No transcription job found for this event")
@@ -513,7 +537,7 @@ async def get_insights(
WHERE tenant_id=$1 AND lead_id=$2 AND status=$3
ORDER BY created_at DESC LIMIT $4
""",
user.role, lead_id, status_filter, limit,
_tenant_scope(user), lead_id, status_filter, limit,
)
else:
rows = await conn.fetch(
@@ -524,7 +548,7 @@ async def get_insights(
WHERE tenant_id=$1 AND lead_id=$2
ORDER BY created_at DESC LIMIT $3
""",
user.role, lead_id, limit,
_tenant_scope(user), lead_id, limit,
)
return {"insights": [dict(r) for r in rows]}
@@ -544,7 +568,7 @@ async def act_on_insight(
SET status=$1, acted_by=$2, acted_at=NOW(), updated_at=NOW()
WHERE recommendation_id=$3 AND tenant_id=$4
""",
body.action, user.user_id, recommendation_id, user.role,
body.action, user.user_id, recommendation_id, _tenant_scope(user),
)
if result == "UPDATE 0":
raise HTTPException(404, "Insight not found")
@@ -568,7 +592,7 @@ async def get_alerts(
async with pool.acquire() as conn:
pending_insights = await conn.fetchval(
"SELECT COUNT(*) FROM insight_recommendations WHERE tenant_id=$1 AND status='pending'",
user.role,
_tenant_scope(user),
)
upcoming_events = await conn.fetchval(
"""
@@ -577,11 +601,11 @@ async def get_alerts(
AND status='confirmed'
AND start_at BETWEEN NOW() AND NOW() + INTERVAL '24 hours'
""",
user.role, user.user_id,
_tenant_scope(user), user.user_id,
)
pending_transcriptions = await conn.fetchval(
"SELECT COUNT(*) FROM edge_transcription_jobs WHERE tenant_id=$1 AND status='pending'",
user.role,
_tenant_scope(user),
)
return {
@@ -620,7 +644,7 @@ async def session_heartbeat(
ORDER BY last_active_at DESC
LIMIT 1
""",
user.role, user.user_id, body.surface_type,
_tenant_scope(user), user.user_id, body.surface_type,
)
if existing_session_id:
@@ -652,7 +676,7 @@ async def session_heartbeat(
END
)
""",
user.role, user.user_id, body.surface_type, body.app_version,
_tenant_scope(user), user.user_id, body.surface_type, body.app_version,
json.dumps(body.metadata), body.screen,
)
return {"status": "ok", "timestamp": _now()}

View File

@@ -0,0 +1,24 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Query, Request
from backend.auth.dependencies import UserPrincipal, get_current_user
from backend.observability import metrics_snapshot
router = APIRouter(prefix="/observability", tags=["Observability"])
@router.get("/request-metrics")
async def request_metrics(
request: Request,
limit: int = Query(default=50, ge=1, le=200),
user: UserPrincipal = Depends(get_current_user),
) -> dict:
return {
"status": "ok",
"data": {
"tenant_id": user.tenant_id,
"metrics": metrics_snapshot(request.app, limit=limit),
},
}

View File

@@ -29,6 +29,10 @@ ROLE_HIERARCHY = {
"ADMIN": 3,
}
def default_tenant_id() -> str:
return os.getenv("VELOCITY_DEFAULT_TENANT_ID", "tenant_velocity").strip() or "tenant_velocity"
# ── Password hashing ──────────────────────────────────────────────────────────
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
@@ -57,12 +61,14 @@ JWT_ALGORITHM = "HS256"
JWT_EXPIRE_HOURS = 8
def create_access_token(user_id: str, role: str) -> str:
def create_access_token(user_id: str, role: str, tenant_id: Optional[str] = None) -> str:
expire = datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRE_HOURS)
normalized_role = role.strip().upper()
normalized_tenant = (tenant_id or default_tenant_id()).strip() or default_tenant_id()
payload = {
"sub": user_id,
"role": normalized_role,
"tenant_id": normalized_tenant,
"exp": expire,
"iat": datetime.now(timezone.utc),
}
@@ -75,6 +81,7 @@ def create_access_token(user_id: str, role: str) -> str:
class UserPrincipal:
user_id: str
role: str
tenant_id: str = default_tenant_id()
@property
def role_level(self) -> int:
@@ -112,7 +119,11 @@ def get_current_user(
headers={"WWW-Authenticate": "Bearer"},
) from exc
return UserPrincipal(user_id=payload["sub"], role=str(payload["role"]).strip().upper())
return UserPrincipal(
user_id=payload["sub"],
role=str(payload["role"]).strip().upper(),
tenant_id=str(payload.get("tenant_id") or default_tenant_id()).strip() or default_tenant_id(),
)
# ── Dependency factory: role gate ─────────────────────────────────────────────

105
backend/auth/routes.py Normal file
View File

@@ -0,0 +1,105 @@
from __future__ import annotations
import os
import re
from datetime import datetime, 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.service import (
list_tenant_users,
login_with_directory,
read_authenticated_user_profile,
)
from backend.auth.user_directory import ensure_user_directory_schema
router = APIRouter()
ASSET_DIR = os.getenv("VELOCITY_ASSET_DIR", "/opt/dlami/nvme/assets")
def _sanitize_filename(value: str) -> str:
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", value).strip("._")
return cleaned or "upload"
class LoginRequest(BaseModel):
email: str
password: str
@router.post("/api/auth/login", tags=["Auth"])
async def login(body: LoginRequest, request: Request):
"""
Authenticate a user and return a JWT.
Credentials are verified against the users_and_roles table.
"""
return await login_with_directory(
app=request.app,
email=body.email,
password=body.password,
)
@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)
@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)
@router.post("/api/auth/profile/avatar", tags=["Auth"])
async def upload_profile_avatar(
request: Request,
file: UploadFile = File(...),
user: UserPrincipal = Depends(get_current_user),
):
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.")
allowed = {"image/png", "image/jpeg", "image/jpg", "image/webp"}
if file.content_type not in allowed:
raise HTTPException(status_code=400, detail="Unsupported avatar format.")
extension = Path(file.filename or "avatar.png").suffix.lower() or ".png"
if extension not in {".png", ".jpg", ".jpeg", ".webp"}:
extension = ".png"
avatar_dir = Path(ASSET_DIR) / "profile_avatars"
avatar_dir.mkdir(parents=True, exist_ok=True)
filename = (
f"{user.user_id}_{_sanitize_filename(Path(file.filename or 'avatar').stem)}_"
f"{int(datetime.now(timezone.utc).timestamp())}{extension}"
)
destination = avatar_dir / filename
contents = await file.read()
destination.write_bytes(contents)
avatar_url = f"/assets/profile_avatars/{filename}"
async with pool.acquire() as conn:
result = await conn.execute(
"""
UPDATE users_and_roles
SET avatar_url = $2
WHERE id = $1::uuid
AND tenant_id = $3
""",
user.user_id,
avatar_url,
user.tenant_id,
)
if result == "UPDATE 0":
raise HTTPException(status_code=404, detail="Authenticated user profile was not found.")
return {"avatar_url": avatar_url}

123
backend/auth/service.py Normal file
View File

@@ -0,0 +1,123 @@
from __future__ import annotations
from typing import Any
from fastapi import HTTPException, status
from backend.auth.dependencies import (
UserPrincipal,
create_access_token,
default_tenant_id,
verify_password,
)
from backend.auth.user_directory import ensure_user_directory_schema
async def _get_pool(app: Any):
pool = getattr(app.state, "db_pool", None)
if pool is None:
raise HTTPException(status_code=503, detail="Database unavailable.")
return pool
async def login_with_directory(*, app: Any, email: str, password: str) -> dict[str, Any]:
await ensure_user_directory_schema(app)
pool = await _get_pool(app)
tenant_fallback = default_tenant_id()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT
id::text,
role,
password_hash,
COALESCE(NULLIF(tenant_id, ''), $2) AS tenant_id
FROM users_and_roles
WHERE email = $1 AND is_active = TRUE
""",
email.strip(),
tenant_fallback,
)
if not row or not verify_password(password, row["password_hash"]):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password.",
)
token = create_access_token(
user_id=row["id"],
role=row["role"],
tenant_id=row["tenant_id"],
)
return {"access_token": token, "token_type": "bearer", "expires_in": 28800}
async def read_authenticated_user_profile(*, app: Any, user: UserPrincipal) -> dict[str, Any]:
await ensure_user_directory_schema(app)
pool = await _get_pool(app)
tenant_scope = user.tenant_id or default_tenant_id()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT
full_name,
email,
avatar_url,
COALESCE(NULLIF(tenant_id, ''), $2) AS tenant_id
FROM users_and_roles
WHERE id = $1::uuid
AND COALESCE(NULLIF(tenant_id, ''), $2) = $2
""",
user.user_id,
tenant_scope,
)
return {
"user_id": user.user_id,
"role": user.role,
"tenant_id": row["tenant_id"] if row else tenant_scope,
"full_name": row["full_name"] if row else None,
"email": row["email"] if row else None,
"avatar_url": row["avatar_url"] if row else None,
}
async def list_tenant_users(*, app: Any, user: UserPrincipal) -> list[dict[str, Any]]:
await ensure_user_directory_schema(app)
pool = await _get_pool(app)
tenant_scope = user.tenant_id or default_tenant_id()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT
id::text AS user_id,
role,
COALESCE(NULLIF(tenant_id, ''), $1) AS tenant_id,
full_name,
email,
avatar_url
FROM users_and_roles
WHERE is_active = TRUE
AND COALESCE(NULLIF(tenant_id, ''), $1) = $2
ORDER BY
COALESCE(NULLIF(full_name, ''), email, id::text) ASC
""",
default_tenant_id(),
tenant_scope,
)
return [
{
"user_id": row["user_id"],
"role": row["role"],
"tenant_id": row["tenant_id"],
"full_name": row["full_name"],
"email": row["email"],
"avatar_url": row["avatar_url"],
}
for row in rows
]

View File

@@ -0,0 +1,45 @@
from __future__ import annotations
from typing import Any
from fastapi import HTTPException
from backend.auth.dependencies import default_tenant_id
_AUTH_USER_DIRECTORY_SCHEMA_CACHE_KEY = "_auth_user_directory_schema_ready"
def _sql_text_literal(value: str) -> str:
return "'" + value.replace("'", "''") + "'"
async def ensure_user_directory_schema(app: Any) -> None:
if getattr(app.state, _AUTH_USER_DIRECTORY_SCHEMA_CACHE_KEY, False):
return
pool = getattr(app.state, "db_pool", None)
if pool is None:
raise HTTPException(status_code=503, detail="Database unavailable.")
tenant_fallback = default_tenant_id()
tenant_default_literal = _sql_text_literal(tenant_fallback)
async with pool.acquire() as conn:
await conn.execute("ALTER TABLE users_and_roles ADD COLUMN IF NOT EXISTS tenant_id TEXT")
await conn.execute(
"""
UPDATE users_and_roles
SET tenant_id = $1
WHERE tenant_id IS NULL OR tenant_id = ''
""",
tenant_fallback,
)
await conn.execute(
f"ALTER TABLE users_and_roles ALTER COLUMN tenant_id SET DEFAULT {tenant_default_literal}"
)
await conn.execute("ALTER TABLE users_and_roles ALTER COLUMN tenant_id SET NOT NULL")
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_users_tenant_active ON users_and_roles (tenant_id, is_active)"
)
setattr(app.state, _AUTH_USER_DIRECTORY_SCHEMA_CACHE_KEY, True)

View File

@@ -0,0 +1,88 @@
from __future__ import annotations
from typing import Any
from fastapi import HTTPException
from backend.auth.dependencies import default_tenant_id
_CANONICAL_CRM_SCHEMA_CACHE_KEY = "_canonical_crm_schema_ready"
def _sql_text_literal(value: str) -> str:
return "'" + value.replace("'", "''") + "'"
async def ensure_canonical_crm_schema(app: Any) -> None:
if getattr(app.state, _CANONICAL_CRM_SCHEMA_CACHE_KEY, False):
return
pool = getattr(app.state, "db_pool", None)
if pool is None:
raise HTTPException(status_code=503, detail="Database unavailable.")
tenant_fallback = default_tenant_id()
tenant_default_literal = _sql_text_literal(tenant_fallback)
tenant_tables = (
"crm_people",
"crm_accounts",
"crm_leads",
"crm_opportunities",
"crm_property_interests",
"intel_interactions",
"intel_reminders",
"intel_qd_scores",
"intel_qd_timeseries",
"workflow_actions",
"workflow_approvals",
"workflow_import_batches",
)
async with pool.acquire() as conn:
for table_name in tenant_tables:
await conn.execute(f"ALTER TABLE {table_name} ADD COLUMN IF NOT EXISTS tenant_id TEXT")
await conn.execute(
f"""
UPDATE {table_name}
SET tenant_id = $1
WHERE tenant_id IS NULL OR tenant_id = ''
""",
tenant_fallback,
)
await conn.execute(
f"ALTER TABLE {table_name} ALTER COLUMN tenant_id SET DEFAULT {tenant_default_literal}"
)
await conn.execute(f"ALTER TABLE {table_name} ALTER COLUMN tenant_id SET NOT NULL")
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_crm_people_tenant_created ON crm_people (tenant_id, created_at DESC)"
)
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_crm_leads_tenant_status ON crm_leads (tenant_id, status, updated_at DESC)"
)
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_crm_opportunities_tenant_stage ON crm_opportunities (tenant_id, stage, updated_at DESC)"
)
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_crm_property_interests_tenant_person ON crm_property_interests (tenant_id, person_id, created_at DESC)"
)
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_intel_interactions_tenant_person ON intel_interactions (tenant_id, person_id, happened_at DESC)"
)
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_intel_reminders_tenant_status ON intel_reminders (tenant_id, status, due_at)"
)
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_intel_qd_scores_tenant_person ON intel_qd_scores (tenant_id, person_id, score_type)"
)
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_intel_qd_timeseries_tenant_person ON intel_qd_timeseries (tenant_id, person_id, timestamp DESC)"
)
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_workflow_actions_tenant_status ON workflow_actions (tenant_id, status, created_at DESC)"
)
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_workflow_import_batches_tenant_lifecycle ON workflow_import_batches (tenant_id, lifecycle, created_at DESC)"
)
setattr(app.state, _CANONICAL_CRM_SCHEMA_CACHE_KEY, True)

View File

@@ -59,6 +59,7 @@ CREATE TABLE IF NOT EXISTS users_and_roles (
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role role_enum NOT NULL DEFAULT 'JUNIOR_BROKER',
tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity',
full_name TEXT,
avatar_url TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
@@ -68,6 +69,7 @@ CREATE TABLE IF NOT EXISTS users_and_roles (
-- Index for login lookups
CREATE INDEX IF NOT EXISTS idx_users_email ON users_and_roles (email);
CREATE INDEX IF NOT EXISTS idx_users_tenant_active ON users_and_roles (tenant_id, is_active);
-- ────────────────────────────────────────────────────────────────────────────
-- TABLE: leads_intelligence (CRM core with QD scoring)

View File

@@ -641,6 +641,34 @@ CREATE TABLE IF NOT EXISTS workflow_agent_runs (
CREATE INDEX IF NOT EXISTS idx_wf_agent_runs_agent ON workflow_agent_runs (agent_name, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_wf_agent_runs_status ON workflow_agent_runs (status);
-- ─────────────────────────────────────────────────────────────────────────────
-- TENANT HARDENING FOR SHARED CRM SURFACES
-- ─────────────────────────────────────────────────────────────────────────────
ALTER TABLE crm_people ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
ALTER TABLE crm_accounts ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
ALTER TABLE crm_leads ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
ALTER TABLE crm_opportunities ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
ALTER TABLE crm_property_interests ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
ALTER TABLE intel_interactions ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
ALTER TABLE intel_reminders ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
ALTER TABLE intel_qd_scores ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
ALTER TABLE intel_qd_timeseries ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
ALTER TABLE workflow_actions ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
ALTER TABLE workflow_approvals ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
ALTER TABLE workflow_import_batches ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
CREATE INDEX IF NOT EXISTS idx_crm_people_tenant_created ON crm_people (tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_crm_leads_tenant_status ON crm_leads (tenant_id, status, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_crm_opportunities_tenant_stage ON crm_opportunities (tenant_id, stage, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_crm_property_interests_tenant_person ON crm_property_interests (tenant_id, person_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_intel_interactions_tenant_person ON intel_interactions (tenant_id, person_id, happened_at DESC);
CREATE INDEX IF NOT EXISTS idx_intel_reminders_tenant_status ON intel_reminders (tenant_id, status, due_at);
CREATE INDEX IF NOT EXISTS idx_intel_qd_scores_tenant_person ON intel_qd_scores (tenant_id, person_id, score_type);
CREATE INDEX IF NOT EXISTS idx_intel_qd_timeseries_tenant_person ON intel_qd_timeseries (tenant_id, person_id, timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_wf_actions_tenant_status ON workflow_actions (tenant_id, status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_wf_import_batches_tenant_lifecycle ON workflow_import_batches (tenant_id, lifecycle, created_at DESC);
-- ─────────────────────────────────────────────────────────────────────────────
-- TRIGGERS: auto-update updated_at
-- ─────────────────────────────────────────────────────────────────────────────

View File

@@ -11,13 +11,12 @@ import os
import json
import asyncio
import logging
import re
from contextlib import asynccontextmanager
from datetime import UTC, datetime
from datetime import datetime, timezone
from pathlib import Path
from typing import Set
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, HTTPException, status, UploadFile, File
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from dotenv import load_dotenv
@@ -62,11 +61,13 @@ 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_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
from backend.auth.dependencies import (
create_access_token, verify_password, get_current_user, UserPrincipal
)
from backend.auth.routes import router as auth_router
from backend.auth.user_directory import ensure_user_directory_schema
from backend.db.pool import create_pool, close_pool
from backend.migrations.runner import apply_migrations
from backend.observability import RequestObservabilityMiddleware
from backend.oracle.router_v1 import router as oracle_v1_router
from backend.routers.cctv import router as cctv_router
from backend.routers.scenes import router as scenes_router
@@ -85,6 +86,11 @@ async def lifespan(app: FastAPI):
try:
app.state.db_pool = await create_pool()
logger.info("asyncpg pool created")
async with app.state.db_pool.acquire() as conn:
applied = await apply_migrations(conn)
if applied:
logger.info("Applied backend migrations: %s", ", ".join(applied))
await ensure_user_directory_schema(app)
except Exception as exc:
logger.error("Failed to create DB pool: %s", exc)
app.state.db_pool = None
@@ -117,6 +123,7 @@ app.add_middleware(
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(RequestObservabilityMiddleware)
# ── Static asset serving (Vault files) ───────────────────────────────────────
@@ -124,11 +131,6 @@ ASSET_DIR = os.getenv("VELOCITY_ASSET_DIR", "/opt/dlami/nvme/assets")
if os.path.isdir(ASSET_DIR):
app.mount("/assets", StaticFiles(directory=ASSET_DIR), name="assets")
def _sanitize_filename(value: str) -> str:
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", value).strip("._")
return cleaned or "upload"
# ── Routers ───────────────────────────────────────────────────────────────────
app.include_router(catalyst_router, prefix="/api/catalyst", tags=["Catalyst"])
@@ -145,150 +147,14 @@ 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(observability_router, prefix="/api", tags=["Observability"])
app.include_router(crm_imports_router, prefix="/api", tags=["CRM Canonical"])
app.include_router(auth_router)
# Public vault link (no /api prefix — shared externally with prospects)
from backend.routers.vault import router as public_vault_router
app.include_router(public_vault_router, prefix="/vault", tags=["Vault Public"])
# ── Auth endpoint ─────────────────────────────────────────────────────────────
from fastapi import HTTPException, status
from pydantic import BaseModel
class LoginRequest(BaseModel):
email: str
password: str
@app.post("/api/auth/login", tags=["Auth"])
async def login(body: LoginRequest):
"""
Authenticate a user and return a JWT.
Credentials are verified against the users_and_roles table.
"""
from backend.db.pool import get_pool
from fastapi import Request
pool = app.state.db_pool
if pool is None:
raise HTTPException(status_code=503, detail="Database unavailable.")
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT id::text, role, password_hash FROM users_and_roles WHERE email = $1 AND is_active = TRUE",
body.email,
)
if not row or not verify_password(body.password, row["password_hash"]):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password.",
)
token = create_access_token(user_id=row["id"], role=row["role"])
return {"access_token": token, "token_type": "bearer", "expires_in": 28800}
@app.get("/api/auth/me", tags=["Auth"])
async def me(user: UserPrincipal = Depends(get_current_user)):
pool = app.state.db_pool
if pool is None:
raise HTTPException(status_code=503, detail="Database unavailable.")
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT full_name, email, avatar_url
FROM users_and_roles
WHERE id = $1::uuid
""",
user.user_id,
)
return {
"user_id": user.user_id,
"role": user.role,
"full_name": row["full_name"] if row else None,
"email": row["email"] if row else None,
"avatar_url": row["avatar_url"] if row else None,
}
@app.get("/api/auth/users", tags=["Auth"])
async def list_auth_users(_: UserPrincipal = Depends(get_current_user)):
pool = app.state.db_pool
if pool is None:
raise HTTPException(status_code=503, detail="Database unavailable.")
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT
id::text AS user_id,
role,
full_name,
email,
avatar_url
FROM users_and_roles
WHERE is_active = TRUE
ORDER BY
COALESCE(NULLIF(full_name, ''), email, id::text) ASC
"""
)
return [
{
"user_id": row["user_id"],
"role": row["role"],
"full_name": row["full_name"],
"email": row["email"],
"avatar_url": row["avatar_url"],
}
for row in rows
]
@app.post("/api/auth/profile/avatar", tags=["Auth"])
async def upload_profile_avatar(
file: UploadFile = File(...),
user: UserPrincipal = Depends(get_current_user),
):
pool = app.state.db_pool
if pool is None:
raise HTTPException(status_code=503, detail="Database unavailable.")
allowed = {"image/png", "image/jpeg", "image/jpg", "image/webp"}
if file.content_type not in allowed:
raise HTTPException(status_code=400, detail="Unsupported avatar format.")
extension = Path(file.filename or "avatar.png").suffix.lower() or ".png"
if extension not in {".png", ".jpg", ".jpeg", ".webp"}:
extension = ".png"
avatar_dir = Path(ASSET_DIR) / "profile_avatars"
avatar_dir.mkdir(parents=True, exist_ok=True)
filename = f"{user.user_id}_{_sanitize_filename(Path(file.filename or 'avatar').stem)}_{int(datetime.now(UTC).timestamp())}{extension}"
destination = avatar_dir / filename
contents = await file.read()
destination.write_bytes(contents)
avatar_url = f"/assets/profile_avatars/{filename}"
async with pool.acquire() as conn:
await conn.execute(
"""
UPDATE users_and_roles
SET avatar_url = $2
WHERE id = $1::uuid
""",
user.user_id,
avatar_url,
)
return {"avatar_url": avatar_url}
# ── Catalyst WebSocket (preserved from v1) ────────────────────────────────────
class _CatalystManager:
@@ -357,7 +223,7 @@ async def crm_ws(ws: WebSocket) -> None:
{
"type": "crm_presence",
"connected_clients": len(_crm_mgr.active),
"timestamp": datetime.now(UTC).isoformat(),
"timestamp": datetime.now(timezone.utc).isoformat(),
}
)
try:
@@ -374,7 +240,7 @@ async def broadcast_live_event(event_type, message, campaign_name=None, value=No
"message": message,
"campaignName": campaign_name,
"value": value,
"timestamp": datetime.now(UTC).isoformat(),
"timestamp": datetime.now(timezone.utc).isoformat(),
}
await _catalyst_mgr.broadcast(payload)
@@ -385,7 +251,7 @@ app.state.broadcast_live_event = broadcast_live_event
async def broadcast_crm_event(payload: dict) -> None:
enriched = {
**payload,
"timestamp": datetime.now(UTC).isoformat(),
"timestamp": datetime.now(timezone.utc).isoformat(),
}
await _crm_mgr.broadcast(enriched)
@@ -404,6 +270,5 @@ async def health() -> dict:
"service": "velocity-backend",
"version": "2.0.0",
"db_pool": "connected" if db_ok else "unavailable",
"timestamp": datetime.now(UTC).isoformat(),
"timestamp": datetime.now(timezone.utc).isoformat(),
}

View File

@@ -0,0 +1,2 @@
"""Velocity backend migration utilities."""

View File

@@ -0,0 +1,102 @@
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
MIGRATIONS_DIR = Path(__file__).resolve().parent / "versions"
@dataclass(frozen=True)
class Migration:
version: str
name: str
path: Path
checksum: str
sql: str
def _checksum(sql: str) -> str:
return hashlib.sha256(sql.encode("utf-8")).hexdigest()
def discover_migrations(directory: Path = MIGRATIONS_DIR) -> list[Migration]:
if not directory.exists():
return []
migrations: list[Migration] = []
for path in sorted(directory.glob("*.sql")):
version, _, name = path.stem.partition("_")
if not version or not name:
raise ValueError(f"Invalid migration filename: {path.name}")
sql = path.read_text(encoding="utf-8")
migrations.append(
Migration(
version=version,
name=name,
path=path,
checksum=_checksum(sql),
sql=sql,
)
)
seen: set[str] = set()
for migration in migrations:
if migration.version in seen:
raise ValueError(f"Duplicate migration version: {migration.version}")
seen.add(migration.version)
return migrations
async def ensure_migration_table(conn) -> None:
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
name TEXT NOT NULL,
checksum TEXT NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"""
)
async def applied_versions(conn) -> dict[str, str]:
await ensure_migration_table(conn)
rows = await conn.fetch("SELECT version, checksum FROM schema_migrations")
return {row["version"]: row["checksum"] for row in rows}
async def apply_migrations(conn, migrations: Iterable[Migration] | None = None) -> list[str]:
pending = list(migrations if migrations is not None else discover_migrations())
applied = await applied_versions(conn)
applied_now: list[str] = []
for migration in pending:
existing_checksum = applied.get(migration.version)
if existing_checksum == migration.checksum:
continue
if existing_checksum and existing_checksum != migration.checksum:
raise RuntimeError(
f"Migration checksum mismatch for {migration.version}; "
"create a new migration instead of editing an applied one."
)
transaction = conn.transaction()
async with transaction:
await conn.execute(migration.sql)
await conn.execute(
"""
INSERT INTO schema_migrations (version, name, checksum)
VALUES ($1, $2, $3)
""",
migration.version,
migration.name,
migration.checksum,
)
applied_now.append(migration.version)
return applied_now

View File

@@ -0,0 +1,22 @@
-- Velocity production observability foundation.
-- Creates a lightweight table for durable request/error telemetry when enabled.
CREATE TABLE IF NOT EXISTS app_request_events (
event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
request_id TEXT NOT NULL,
method TEXT NOT NULL,
path TEXT NOT NULL,
status_code INTEGER NOT NULL,
duration_ms DOUBLE PRECISION NOT NULL,
tenant_id TEXT,
user_id UUID,
error_type TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_app_request_events_created_at
ON app_request_events (created_at DESC);
CREATE INDEX IF NOT EXISTS idx_app_request_events_path_status
ON app_request_events (path, status_code, created_at DESC);

View File

@@ -0,0 +1,30 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS user_calendar_events (
calendar_event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
owner_user_id TEXT NOT NULL,
lead_id TEXT,
source_event_id UUID,
title TEXT NOT NULL,
description TEXT,
start_at TIMESTAMPTZ NOT NULL,
end_at TIMESTAMPTZ NOT NULL,
all_day BOOLEAN NOT NULL DEFAULT FALSE,
status TEXT NOT NULL DEFAULT 'confirmed'
CHECK (status IN ('tentative', 'confirmed', 'done', 'cancelled')),
reminder_minutes INTEGER[] NOT NULL DEFAULT '{15}'::INTEGER[],
created_by TEXT NOT NULL DEFAULT 'user'
CHECK (created_by IN ('user', 'nemoclaw_suggested', 'operator_import')),
is_nemoclaw_confirmed BOOLEAN NOT NULL DEFAULT FALSE,
location TEXT,
metadata 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_calendar_events_owner
ON user_calendar_events (tenant_id, owner_user_id, start_at);
CREATE INDEX IF NOT EXISTS idx_calendar_events_lead
ON user_calendar_events (tenant_id, lead_id, start_at);

View File

@@ -0,0 +1,6 @@
ALTER TABLE user_calendar_events
DROP CONSTRAINT IF EXISTS user_calendar_events_status_check;
ALTER TABLE user_calendar_events
ADD CONSTRAINT user_calendar_events_status_check
CHECK (status IN ('tentative', 'confirmed', 'done', 'cancelled'));

103
backend/observability.py Normal file
View File

@@ -0,0 +1,103 @@
from __future__ import annotations
import logging
import time
import uuid
from collections import deque
from dataclasses import asdict, dataclass
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
logger = logging.getLogger("velocity.observability")
@dataclass(frozen=True)
class RequestMetric:
request_id: str
method: str
path: str
status_code: int
duration_ms: float
class RequestObservabilityMiddleware(BaseHTTPMiddleware):
def __init__(self, app, *, max_metrics: int = 500) -> None:
super().__init__(app)
self.max_metrics = max_metrics
async def dispatch(self, request: Request, call_next):
request_id = request.headers.get("x-request-id") or str(uuid.uuid4())
request.state.request_id = request_id
started = time.perf_counter()
status_code = 500
try:
response = await call_next(request)
status_code = response.status_code
return self._finalize(request, response, request_id, started, status_code)
except Exception:
duration_ms = (time.perf_counter() - started) * 1000
self._record_metric(request, request_id, status_code, duration_ms)
logger.exception(
"request_failed",
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"duration_ms": round(duration_ms, 2),
},
)
raise
def _finalize(
self,
request: Request,
response: Response,
request_id: str,
started: float,
status_code: int,
) -> Response:
duration_ms = (time.perf_counter() - started) * 1000
response.headers["X-Request-ID"] = request_id
response.headers["X-Response-Time-Ms"] = f"{duration_ms:.2f}"
self._record_metric(request, request_id, status_code, duration_ms)
logger.info(
"request_completed",
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"status_code": status_code,
"duration_ms": round(duration_ms, 2),
},
)
return response
def _record_metric(
self,
request: Request,
request_id: str,
status_code: int,
duration_ms: float,
) -> None:
metrics = getattr(request.app.state, "request_metrics", None)
if metrics is None:
metrics = deque(maxlen=self.max_metrics)
request.app.state.request_metrics = metrics
metrics.append(
RequestMetric(
request_id=request_id,
method=request.method,
path=request.url.path,
status_code=status_code,
duration_ms=round(duration_ms, 2),
)
)
def metrics_snapshot(app, *, limit: int = 50) -> list[dict]:
metrics = getattr(app.state, "request_metrics", deque())
return [asdict(metric) for metric in list(metrics)[-limit:]][::-1]

View File

@@ -223,7 +223,7 @@ CREATE TABLE IF NOT EXISTS user_calendar_events (
end_at TIMESTAMPTZ NOT NULL,
all_day BOOLEAN NOT NULL DEFAULT FALSE,
status TEXT NOT NULL DEFAULT 'confirmed'
CHECK (status IN ('tentative','confirmed','cancelled')),
CHECK (status IN ('tentative','confirmed','done','cancelled')),
reminder_minutes INTEGER[] NOT NULL DEFAULT '{15}'::INTEGER[],
created_by TEXT NOT NULL
CHECK (created_by IN ('user','nemoclaw_suggested','operator_import')),

View File

@@ -0,0 +1,850 @@
#!/usr/bin/env python3
"""
Seed realistic, idempotent iPad investor-demo data into the current operator tenant.
The script writes only canonical Velocity domains used by the iPad app:
crm_*, intel_*, workflow_*, inventory_*, mobile-edge events, and calendar events.
Rows are tagged with metadata_json/source identifiers so they are auditable and safe
to re-run without duplication.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from dotenv import load_dotenv
try:
import asyncpg
except ModuleNotFoundError: # pragma: no cover - exercised by operator environment
asyncpg = None # type: ignore[assignment]
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")
def _load_env() -> None:
repo_root = Path(__file__).resolve().parents[2]
for candidate in (repo_root / "backend" / ".env", repo_root / ".env"):
if candidate.exists():
load_dotenv(candidate, override=False)
load_dotenv(override=False)
def _stable_uuid(tenant_id: str, key: str) -> str:
return str(uuid.uuid5(NAMESPACE, f"{tenant_id}:{key}"))
def _json(value: Any) -> str:
return json.dumps(value, separators=(",", ":"), ensure_ascii=True)
def _db_kwargs() -> dict[str, Any]:
if os.getenv("DATABASE_URL"):
return {"dsn": os.environ["DATABASE_URL"]}
required = ["VELOCITY_DB_NAME", "VELOCITY_DB_USER", "VELOCITY_DB_PASSWORD"]
missing = [key for key in required if not os.getenv(key)]
if missing:
raise RuntimeError(
"Missing database configuration: "
+ ", ".join(missing)
+ ". Set DATABASE_URL or VELOCITY_DB_* variables before running the seed."
)
return {
"host": os.getenv("VELOCITY_DB_HOST", "localhost"),
"port": int(os.getenv("VELOCITY_DB_PORT", "5432")),
"database": os.environ["VELOCITY_DB_NAME"],
"user": os.environ["VELOCITY_DB_USER"],
"password": os.environ["VELOCITY_DB_PASSWORD"],
}
def _expected_counts() -> dict[str, int]:
return {
"people": len(DEMO_CLIENTS),
"leads": len(DEMO_CLIENTS),
"projects": len(PROJECTS),
"properties": len(PROJECTS),
"interests": len(DEMO_CLIENTS),
"opportunities": len(DEMO_CLIENTS),
"scores": len(DEMO_CLIENTS) * 3,
"interactions": len(DEMO_CLIENTS) * 3,
"edge_events": len(DEMO_CLIENTS),
"reminders": len(DEMO_CLIENTS),
"calendar_events": len(DEMO_CLIENTS),
"import_batches": 1,
"import_proposals": 3,
}
async def _resolve_operator(conn: asyncpg.Connection, email: str, tenant_override: str | None) -> tuple[str, str | None]:
if tenant_override:
user_id = await conn.fetchval(
"SELECT id::text FROM users_and_roles WHERE email = $1 AND tenant_id = $2 LIMIT 1",
email,
tenant_override,
)
return tenant_override, user_id
row = await conn.fetchrow(
"""
SELECT id::text AS user_id, tenant_id
FROM users_and_roles
WHERE email = $1
AND is_active = TRUE
ORDER BY last_login DESC NULLS LAST, created_at DESC
LIMIT 1
""",
email,
)
if row:
return row["tenant_id"], row["user_id"]
tenant_id = os.getenv("VELOCITY_DEFAULT_TENANT_ID", "tenant_velocity")
return tenant_id, None
DEMO_CLIENTS = [
{
"key": "meera-sen",
"name": "Meera Sen",
"email": "meera.sen.investor@demo.desineuron.in",
"phone": "+91-98745-11820",
"buyer_type": "hni_end_user",
"city": "Kolkata",
"nationality": "Indian",
"persona": ["family_upgrader", "waterfront_preference"],
"budget": "12-18 Cr",
"urgency": "high",
"status": "qualified",
"project": "Atri Aqua Sky Residences",
"configuration": "4BHK Sky Villa",
"unit": "Tower A / High floor",
"budget_min": 120000000,
"budget_max": 180000000,
"stage": "proposal",
"value": 154000000,
"probability": 72,
"next_action": "Share revised payment schedule and club-deck walkthrough slots.",
"scores": (0.91, 0.86, 0.88),
},
{
"key": "arjun-malhotra",
"name": "Arjun Malhotra",
"email": "arjun.malhotra.nri@demo.desineuron.in",
"phone": "+91-98102-44591",
"buyer_type": "nri_investor",
"city": "Dubai",
"nationality": "Indian",
"persona": ["nri_portfolio_buyer", "rental_yield_focused"],
"budget": "8-12 Cr",
"urgency": "medium",
"status": "site_visit_scheduled",
"project": "Alipore Azure Residences",
"configuration": "3BHK Signature",
"unit": "Tower B / 21st floor",
"budget_min": 80000000,
"budget_max": 120000000,
"stage": "site_visit",
"value": 98000000,
"probability": 61,
"next_action": "Coordinate Saturday family video walkthrough and NRI remittance checklist.",
"scores": (0.82, 0.79, 0.63),
},
{
"key": "devika-roy",
"name": "Devika Roy",
"email": "devika.roy.familyoffice@demo.desineuron.in",
"phone": "+91-99033-28741",
"buyer_type": "family_office",
"city": "Mumbai",
"nationality": "Indian",
"persona": ["portfolio_allocator", "low_visibility_buyer"],
"budget": "20-30 Cr",
"urgency": "critical",
"status": "negotiation",
"project": "Victoria Gardens Private Residences",
"configuration": "Penthouse Duplex",
"unit": "Private elevator stack",
"budget_min": 200000000,
"budget_max": 300000000,
"stage": "negotiation",
"value": 265000000,
"probability": 78,
"next_action": "Prepare founder-level commercial note before family office review.",
"scores": (0.95, 0.91, 0.96),
},
{
"key": "rohan-kapoor",
"name": "Rohan Kapoor",
"email": "rohan.kapoor.startup@demo.desineuron.in",
"phone": "+91-98311-73654",
"buyer_type": "founder_buyer",
"city": "Bengaluru",
"nationality": "Indian",
"persona": ["founder_liquidity_event", "design_led_buyer"],
"budget": "6-10 Cr",
"urgency": "high",
"status": "contacted",
"project": "Salt Lake Atelier Homes",
"configuration": "3.5BHK Garden Home",
"unit": "Podium garden facing",
"budget_min": 60000000,
"budget_max": 100000000,
"stage": "qualified",
"value": 83500000,
"probability": 54,
"next_action": "Send design moodboard and schedule Dream Weaver room concept.",
"scores": (0.76, 0.83, 0.81),
},
{
"key": "saira-hussain",
"name": "Saira Hussain",
"email": "saira.hussain.doctor@demo.desineuron.in",
"phone": "+91-97482-66019",
"buyer_type": "end_user",
"city": "Kolkata",
"nationality": "Indian",
"persona": ["quiet_luxury", "school_proximity"],
"budget": "4-6 Cr",
"urgency": "medium",
"status": "site_visited",
"project": "Ballygunge Meridian",
"configuration": "3BHK",
"unit": "South-east corner",
"budget_min": 40000000,
"budget_max": 60000000,
"stage": "proposal",
"value": 52000000,
"probability": 66,
"next_action": "Share school-route comparison and revised parking availability.",
"scores": (0.74, 0.68, 0.62),
},
{
"key": "vikram-jalan",
"name": "Vikram Jalan",
"email": "vikram.jalan.broker@demo.desineuron.in",
"phone": "+91-98300-91274",
"buyer_type": "broker_referral",
"city": "Kolkata",
"nationality": "Indian",
"persona": ["broker_network", "bulk_referral_potential"],
"budget": "15-25 Cr",
"urgency": "high",
"status": "booking_initiated",
"project": "Atri Aqua Sky Residences",
"configuration": "4BHK River Deck",
"unit": "Two adjacent units",
"budget_min": 150000000,
"budget_max": 250000000,
"stage": "booking",
"value": 212000000,
"probability": 84,
"next_action": "Confirm booking amount routing and broker mandate documentation.",
"scores": (0.88, 0.77, 0.89),
},
]
PROJECTS = {
"Atri Aqua Sky Residences": {
"developer": "Atri Group",
"micro_market": "Batanagar Riverside",
"address": "Maheshtala Riverside Corridor, Kolkata",
"property_type": "apartment",
"location": {"city": "Kolkata", "district": "Maheshtala", "lat": 22.4981, "lng": 88.2291},
"price_bands": [
{"unitType": "3BHK", "minINR": 72000000, "maxINR": 98000000},
{"unitType": "4BHK Sky Villa", "minINR": 130000000, "maxINR": 190000000},
],
"unit_mix": [{"bedrooms": 3, "count": 42, "sizeSqft": 2450}, {"bedrooms": 4, "count": 18, "sizeSqft": 3800}],
},
"Alipore Azure Residences": {
"developer": "Meridian Urban Estates",
"micro_market": "Alipore",
"address": "Judges Court Road, Alipore, Kolkata",
"property_type": "apartment",
"location": {"city": "Kolkata", "district": "Alipore", "lat": 22.5288, "lng": 88.3309},
"price_bands": [{"unitType": "3BHK Signature", "minINR": 85000000, "maxINR": 125000000}],
"unit_mix": [{"bedrooms": 3, "count": 36, "sizeSqft": 2850}, {"bedrooms": 4, "count": 16, "sizeSqft": 4100}],
},
"Victoria Gardens Private Residences": {
"developer": "Heritage Habitat",
"micro_market": "Maidan",
"address": "Queen's Way Precinct, Kolkata",
"property_type": "penthouse",
"location": {"city": "Kolkata", "district": "Maidan", "lat": 22.5448, "lng": 88.3426},
"price_bands": [{"unitType": "Penthouse Duplex", "minINR": 220000000, "maxINR": 320000000}],
"unit_mix": [{"bedrooms": 5, "count": 8, "sizeSqft": 6200}],
},
"Salt Lake Atelier Homes": {
"developer": "Studio Habitat",
"micro_market": "Salt Lake Sector V",
"address": "EM Bypass Connector, Salt Lake, Kolkata",
"property_type": "apartment",
"location": {"city": "Kolkata", "district": "Salt Lake", "lat": 22.5797, "lng": 88.4353},
"price_bands": [{"unitType": "3.5BHK Garden Home", "minINR": 68000000, "maxINR": 98000000}],
"unit_mix": [{"bedrooms": 3, "count": 28, "sizeSqft": 2350}],
},
"Ballygunge Meridian": {
"developer": "Eastern Crest Realty",
"micro_market": "Ballygunge",
"address": "Ballygunge Circular Road, Kolkata",
"property_type": "apartment",
"location": {"city": "Kolkata", "district": "Ballygunge", "lat": 22.5276, "lng": 88.3651},
"price_bands": [{"unitType": "3BHK", "minINR": 42000000, "maxINR": 62000000}],
"unit_mix": [{"bedrooms": 3, "count": 54, "sizeSqft": 1780}],
},
}
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)
owner_user_ref = operator_user_id or f"{SEED_SOURCE}:operator"
counts = {
"people": 0,
"leads": 0,
"projects": 0,
"properties": 0,
"interests": 0,
"opportunities": 0,
"scores": 0,
"interactions": 0,
"edge_events": 0,
"reminders": 0,
"calendar_events": 0,
"import_batches": 0,
"import_proposals": 0,
}
if dry_run:
return counts
project_ids: dict[str, str] = {}
for name, project in PROJECTS.items():
project_id = _stable_uuid(tenant_id, f"project:{name}")
project_ids[name] = project_id
await conn.execute(
"""
INSERT INTO inventory_projects (
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
)
ON CONFLICT (project_name) DO UPDATE SET
developer_name = EXCLUDED.developer_name,
micro_market = EXCLUDED.micro_market,
address = EXCLUDED.address,
total_units = EXCLUDED.total_units,
project_status = EXCLUDED.project_status,
location_json = EXCLUDED.location_json,
amenities_json = EXCLUDED.amenities_json,
metadata_json = inventory_projects.metadata_json || EXCLUDED.metadata_json,
updated_at = NOW()
""",
project_id,
name,
project["developer"],
project["micro_market"],
project["address"],
sum(item["count"] for item in project["unit_mix"]),
_json(project["location"]),
_json(["concierge", "private club", "fitness studio", "visitor lounge", "ev charging"]),
_json({"seed_source": SEED_SOURCE, "ipad_demo": True}),
)
counts["projects"] += 1
property_id = _stable_uuid(tenant_id, f"inventory-property:{name}")
await conn.execute(
"""
INSERT INTO inventory_properties (
property_id, tenant_id, source_id, project_name, developer_name,
location, property_type, price_bands, unit_mix, amenities,
status, validation_state, ingested_at, created_at, updated_at
) VALUES (
$1::uuid, $2, $3, $4, $5, $6::jsonb, $7, $8::jsonb, $9::jsonb,
$10, 'active', $11::jsonb, NOW(), NOW(), NOW()
)
ON CONFLICT (property_id) DO UPDATE SET
project_name = EXCLUDED.project_name,
developer_name = EXCLUDED.developer_name,
location = EXCLUDED.location,
property_type = EXCLUDED.property_type,
price_bands = EXCLUDED.price_bands,
unit_mix = EXCLUDED.unit_mix,
amenities = EXCLUDED.amenities,
status = 'active',
validation_state = EXCLUDED.validation_state,
updated_at = NOW()
""",
property_id,
tenant_id,
f"{SEED_SOURCE}:{name}",
name,
project["developer"],
_json(project["location"]),
project["property_type"],
_json(project["price_bands"]),
_json(project["unit_mix"]),
["concierge", "clubhouse", "security", "ev charging", "landscaped deck"],
_json({"seed_source": SEED_SOURCE, "validated_for_ipad_demo": True}),
)
counts["properties"] += 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']}")
opportunity_id = _stable_uuid(tenant_id, f"opportunity:{client['key']}")
project_id = project_ids[client["project"]]
await conn.execute(
"""
INSERT INTO crm_people (
person_id, tenant_id, full_name, primary_email, primary_phone, city,
nationality, buyer_type, persona_labels, source_confidence,
metadata_json, created_at, updated_at
) VALUES (
$1::uuid, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, 0.98,
$10::jsonb, NOW() - ($11::int * INTERVAL '2 days'), NOW()
)
ON CONFLICT (person_id) DO UPDATE SET
full_name = EXCLUDED.full_name,
primary_email = EXCLUDED.primary_email,
primary_phone = EXCLUDED.primary_phone,
city = EXCLUDED.city,
nationality = EXCLUDED.nationality,
buyer_type = EXCLUDED.buyer_type,
persona_labels = EXCLUDED.persona_labels,
source_confidence = EXCLUDED.source_confidence,
metadata_json = crm_people.metadata_json || EXCLUDED.metadata_json,
updated_at = NOW()
""",
person_id,
tenant_id,
client["name"],
client["email"],
client["phone"],
client["city"],
client["nationality"],
client["buyer_type"],
_json(client["persona"]),
_json({"seed_source": SEED_SOURCE, "investor_demo": True, "source_note": "iPad production readiness seed"}),
index,
)
counts["people"] += 1
await conn.execute(
"""
INSERT INTO crm_leads (
lead_id, tenant_id, person_id, source_system, status, budget_band,
urgency, financing_posture, timeline_to_decision, objections,
motivations, assigned_user_id, metadata_json, created_at, updated_at
) VALUES (
$1::uuid, $2, $3::uuid, 'ipad_investor_demo',
$4::crm_lead_status, $5, $6, $7, $8, $9::jsonb, $10::jsonb,
$11::uuid, $12::jsonb, NOW() - ($13::int * INTERVAL '2 days'), NOW()
)
ON CONFLICT (lead_id) DO UPDATE SET
status = EXCLUDED.status,
budget_band = EXCLUDED.budget_band,
urgency = EXCLUDED.urgency,
financing_posture = EXCLUDED.financing_posture,
timeline_to_decision = EXCLUDED.timeline_to_decision,
objections = EXCLUDED.objections,
motivations = EXCLUDED.motivations,
assigned_user_id = EXCLUDED.assigned_user_id,
metadata_json = crm_leads.metadata_json || EXCLUDED.metadata_json,
updated_at = NOW()
""",
lead_id,
tenant_id,
person_id,
client["status"],
client["budget"],
client["urgency"],
"cash_and_structured_payment" if client["budget_min"] >= 100000000 else "bank_loan_preapproved",
"30_days" if client["urgency"] in {"high", "critical"} else "60_to_90_days",
_json(["needs_family_alignment"] if client["urgency"] != "critical" else ["price_protection", "privacy"]),
_json(["upgrade_primary_home", "wealth_preservation", "status_address"]),
operator_user_id,
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
index,
)
counts["leads"] += 1
await conn.execute(
"""
INSERT INTO crm_property_interests (
interest_id, tenant_id, person_id, lead_id, project_id, project_name,
unit_preference, configuration, budget_min, budget_max, priority, notes, created_at
) VALUES (
$1::uuid, $2, $3::uuid, $4::uuid, $5::uuid, $6, $7, $8, $9, $10, 1, $11, NOW()
)
ON CONFLICT (interest_id) DO UPDATE SET
project_name = EXCLUDED.project_name,
unit_preference = EXCLUDED.unit_preference,
configuration = EXCLUDED.configuration,
budget_min = EXCLUDED.budget_min,
budget_max = EXCLUDED.budget_max,
notes = EXCLUDED.notes
""",
_stable_uuid(tenant_id, f"interest:{client['key']}"),
tenant_id,
person_id,
lead_id,
project_id,
client["project"],
client["unit"],
client["configuration"],
client["budget_min"],
client["budget_max"],
f"Seeded for iPad investor demo by {SEED_SOURCE}.",
)
counts["interests"] += 1
await conn.execute(
"""
INSERT INTO crm_opportunities (
opportunity_id, tenant_id, lead_id, project_id, stage, value,
probability, expected_close_date, next_action, notes, metadata_json,
created_at, updated_at
) VALUES (
$1::uuid, $2, $3::uuid, $4::uuid, $5::crm_opportunity_stage, $6,
$7, $8::date, $9, $10, $11::jsonb, NOW() - ($12::int * INTERVAL '1 day'), NOW()
)
ON CONFLICT (opportunity_id) DO UPDATE SET
project_id = EXCLUDED.project_id,
stage = EXCLUDED.stage,
value = EXCLUDED.value,
probability = EXCLUDED.probability,
expected_close_date = EXCLUDED.expected_close_date,
next_action = EXCLUDED.next_action,
notes = EXCLUDED.notes,
metadata_json = crm_opportunities.metadata_json || EXCLUDED.metadata_json,
updated_at = NOW()
""",
opportunity_id,
tenant_id,
lead_id,
project_id,
client["stage"],
client["value"],
client["probability"],
(now + timedelta(days=21 + index * 4)).date().isoformat(),
client["next_action"],
"Investor-demo opportunity with realistic project, value, and next-step context.",
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
index,
)
counts["opportunities"] += 1
for score_type, value in zip(("intent_score", "engagement_score", "urgency_score"), client["scores"]):
await conn.execute(
"""
INSERT INTO intel_qd_scores (
qd_id, tenant_id, person_id, score_type, current_value,
computed_at, evidence_refs_json, reasoning, metadata_json
) VALUES (
$1::uuid, $2, $3::uuid, $4, $5, NOW(), $6::jsonb, $7, $8::jsonb
)
ON CONFLICT (person_id, score_type) DO UPDATE SET
tenant_id = EXCLUDED.tenant_id,
current_value = EXCLUDED.current_value,
computed_at = NOW(),
evidence_refs_json = EXCLUDED.evidence_refs_json,
reasoning = EXCLUDED.reasoning,
metadata_json = intel_qd_scores.metadata_json || EXCLUDED.metadata_json
""",
_stable_uuid(tenant_id, f"qd:{client['key']}:{score_type}"),
tenant_id,
person_id,
score_type,
value,
_json([f"seed:{client['key']}:interaction"]),
f"{client['name']} shows {score_type.replace('_', ' ')} from recent budget, project, and follow-up signals.",
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
)
counts["scores"] += 1
interaction_templates = [
("whatsapp", "message", now - timedelta(hours=6 + index), "Confirmed budget band and asked for a project comparison deck."),
("phone", "call", now - timedelta(days=1, hours=index), "Discussed decision timeline, family alignment, and next visit window."),
("site_visit", "visit", now - timedelta(days=3 + index), f"Reviewed {client['project']} and shortlisted {client['configuration']}."),
]
for event_index, (channel, interaction_type, happened_at, summary) in enumerate(interaction_templates):
interaction_id = _stable_uuid(tenant_id, f"interaction:{client['key']}:{event_index}")
await conn.execute(
"""
INSERT INTO intel_interactions (
interaction_id, tenant_id, person_id, lead_id, channel, interaction_type,
happened_at, summary, source_ref, metadata_json, created_at
) VALUES (
$1::uuid, $2, $3::uuid, $4::uuid, $5::intel_channel, $6,
$7, $8, $9, $10::jsonb, NOW()
)
ON CONFLICT (interaction_id) DO UPDATE SET
happened_at = EXCLUDED.happened_at,
summary = EXCLUDED.summary,
metadata_json = intel_interactions.metadata_json || EXCLUDED.metadata_json
""",
interaction_id,
tenant_id,
person_id,
lead_id,
channel,
interaction_type,
happened_at,
summary,
f"{SEED_SOURCE}:{client['key']}:{event_index}",
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
)
counts["interactions"] += 1
edge_event_id = _stable_uuid(tenant_id, f"edge-event:{client['key']}")
await conn.execute(
"""
INSERT INTO edge_communication_events (
event_id, tenant_id, lead_id, channel, direction, provider,
capture_mode, consent_state, timestamp, duration_seconds,
summary, raw_reference, provider_metadata, created_at, updated_at
) VALUES (
$1::uuid, $2, $3, 'whatsapp_message', 'inbound', 'operator_seed',
'operator_note', 'granted', $4, NULL, $5, $6, $7::jsonb, NOW(), NOW()
)
ON CONFLICT (event_id) DO UPDATE SET
timestamp = EXCLUDED.timestamp,
summary = EXCLUDED.summary,
provider_metadata = EXCLUDED.provider_metadata,
updated_at = NOW()
""",
edge_event_id,
tenant_id,
lead_id,
now - timedelta(minutes=25 + index * 12),
f"{client['name']} asked the operator to proceed with the next step: {client['next_action']}",
f"{SEED_SOURCE}:{client['key']}",
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
)
counts["edge_events"] += 1
reminder_id = _stable_uuid(tenant_id, f"reminder:{client['key']}")
await conn.execute(
"""
INSERT INTO intel_reminders (
reminder_id, tenant_id, person_id, lead_id, opportunity_id, reminder_type,
title, notes, due_at, status, assigned_to, created_by_type, priority,
metadata_json, created_at
) VALUES (
$1::uuid, $2, $3::uuid, $4::uuid, $5::uuid, 'follow_up',
$6, $7, $8, 'pending', $9::uuid, 'human', $10, $11::jsonb, NOW()
)
ON CONFLICT (reminder_id) DO UPDATE SET
title = EXCLUDED.title,
notes = EXCLUDED.notes,
due_at = EXCLUDED.due_at,
status = 'pending',
assigned_to = EXCLUDED.assigned_to,
priority = EXCLUDED.priority,
metadata_json = intel_reminders.metadata_json || EXCLUDED.metadata_json
""",
reminder_id,
tenant_id,
person_id,
lead_id,
opportunity_id,
f"Follow up with {client['name']} on {client['project']}",
client["next_action"],
now + timedelta(hours=3 + index * 4),
operator_user_id,
"urgent" if client["urgency"] == "critical" else ("high" if client["urgency"] == "high" else "normal"),
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
)
counts["reminders"] += 1
calendar_id = _stable_uuid(tenant_id, f"calendar:{client['key']}")
start_at = now + timedelta(days=1 + (index % 3), hours=2 + index)
await conn.execute(
"""
INSERT INTO user_calendar_events (
calendar_event_id, tenant_id, owner_user_id, lead_id, source_event_id,
title, description, start_at, end_at, all_day, status,
reminder_minutes, created_by, is_nemoclaw_confirmed, location,
metadata, created_at, updated_at
) VALUES (
$1::uuid, $2, $3, $4, $5::uuid, $6, $7, $8, $9, FALSE, 'confirmed',
ARRAY[15, 60], 'user', TRUE, $10, $11::jsonb, NOW(), NOW()
)
ON CONFLICT (calendar_event_id) DO UPDATE SET
title = EXCLUDED.title,
description = EXCLUDED.description,
start_at = EXCLUDED.start_at,
end_at = EXCLUDED.end_at,
status = 'confirmed',
reminder_minutes = EXCLUDED.reminder_minutes,
location = EXCLUDED.location,
metadata = EXCLUDED.metadata,
updated_at = NOW()
""",
calendar_id,
tenant_id,
owner_user_ref,
lead_id,
edge_event_id,
f"{client['name']} - {client['configuration']} review",
client["next_action"],
start_at,
start_at + timedelta(minutes=45),
client["project"],
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
)
counts["calendar_events"] += 1
batch_id = _stable_uuid(tenant_id, "workflow-import-batch:investor-demo")
await conn.execute(
"""
INSERT INTO workflow_import_batches (
batch_id, tenant_id, source_system, uploaded_filename, mime_type, storage_ref,
row_count, mapped_count, unresolved_count, canonical_count, uploaded_by,
lifecycle, mapping_manifest, errors_json, metadata_json, created_at, updated_at
) VALUES (
$1::uuid, $2, 'sales_ops_csv', 'investor_demo_priority_clients.csv', 'text/csv',
$3, 6, 5, 1, 0, $4::uuid, 'proposed',
$5::jsonb, '[]'::jsonb, $6::jsonb, NOW() - INTERVAL '2 hours', NOW()
)
ON CONFLICT (batch_id) DO UPDATE SET
row_count = EXCLUDED.row_count,
mapped_count = EXCLUDED.mapped_count,
unresolved_count = EXCLUDED.unresolved_count,
lifecycle = EXCLUDED.lifecycle,
mapping_manifest = EXCLUDED.mapping_manifest,
metadata_json = workflow_import_batches.metadata_json || EXCLUDED.metadata_json,
updated_at = NOW()
""",
batch_id,
tenant_id,
f"s3://velocity-demo/{SEED_SOURCE}/investor_demo_priority_clients.csv",
operator_user_id,
_json({"mapped": {"Name": "full_name", "Phone": "primary_phone", "Budget": "budget_band"}}),
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
)
counts["import_batches"] += 1
for row_number, client in enumerate(DEMO_CLIENTS[:3], start=1):
action_id = _stable_uuid(tenant_id, f"workflow-import-proposal:{client['key']}")
payload = {
"batch_id": batch_id,
"row_number": row_number,
"canonical_payload": {
"full_name": client["name"],
"primary_phone": client["phone"],
"buyer_type": client["buyer_type"],
"budget_band": client["budget"],
"project_name": client["project"],
},
"raw_row": {
"Name": client["name"],
"Phone": client["phone"],
"Budget": client["budget"],
"Project": client["project"],
},
"unresolved_fields": [] if row_number < 3 else ["preferred_visit_time"],
"missing_required": [],
"confidence": 0.92 - (row_number * 0.04),
"review_required": True,
}
await conn.execute(
"""
INSERT INTO workflow_actions (
action_id, tenant_id, action_type, target_domain, target_entity_ref,
proposal_payload, reasoning_summary, evidence_refs, confidence,
status, approval_required, created_by_agent, created_at, updated_at
) VALUES (
$1::uuid, $2, 'import_proposal', 'crm', $3,
$4::jsonb, $5, $6::jsonb, $7, 'pending', TRUE,
'velocity_ipad_seed', NOW() - INTERVAL '90 minutes', NOW()
)
ON CONFLICT (action_id) DO UPDATE SET
proposal_payload = EXCLUDED.proposal_payload,
reasoning_summary = EXCLUDED.reasoning_summary,
evidence_refs = EXCLUDED.evidence_refs,
confidence = EXCLUDED.confidence,
status = 'pending',
approval_required = TRUE,
updated_at = NOW()
""",
action_id,
tenant_id,
client["email"],
_json(payload),
f"Mapped {client['name']} from realistic investor-demo CRM import.",
_json([f"batch:{batch_id}", f"seed_source:{SEED_SOURCE}"]),
payload["confidence"],
)
counts["import_proposals"] += 1
return counts
async def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--operator-email", default=os.getenv("VELOCITY_DEMO_OPERATOR_EMAIL", DEFAULT_OPERATOR_EMAIL))
parser.add_argument("--tenant-id", default=os.getenv("VELOCITY_DEMO_TENANT_ID"))
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
_load_env()
if asyncpg is None:
raise RuntimeError(
"asyncpg is not installed. Install backend requirements first: "
"python3 -m pip install -r backend/requirements.txt"
)
if args.dry_run:
try:
db_kwargs = _db_kwargs()
except RuntimeError as exc:
print(
json.dumps(
{
"status": "dry_run_without_db",
"seed_source": SEED_SOURCE,
"operator_email": args.operator_email,
"database_note": str(exc),
"expected_counts": _expected_counts(),
},
indent=2,
)
)
return
else:
db_kwargs = _db_kwargs()
conn = await asyncpg.connect(**db_kwargs)
try:
tenant_id, operator_user_id = await _resolve_operator(conn, args.operator_email, args.tenant_id)
counts = _expected_counts() if args.dry_run else await seed(conn, tenant_id, operator_user_id)
print(
json.dumps(
{
"status": "dry_run" if args.dry_run else "seeded",
"seed_source": SEED_SOURCE,
"tenant_id": tenant_id,
"operator_email": args.operator_email,
"operator_user_id": operator_user_id,
"counts": counts,
},
indent=2,
)
)
finally:
await conn.close()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -11,12 +11,35 @@ As specified in Doc 07 (Client360Snapshot contract) and Doc 08 (Adapter Spec).
"""
from __future__ import annotations
import json
import logging
from typing import Any
logger = logging.getLogger("velocity.client_graph.aggregation")
def _json_string_list(value: Any) -> list[str]:
"""Normalize canonical array fields that may arrive as jsonb, text[], or JSON text."""
if value is None:
return []
if isinstance(value, list | tuple):
return [str(item) for item in value if item is not None]
if isinstance(value, str):
normalized = value.strip()
if not normalized:
return []
try:
parsed = json.loads(normalized)
except json.JSONDecodeError:
return [normalized]
if isinstance(parsed, list):
return [str(item) for item in parsed if item is not None]
if parsed is None:
return []
return [str(parsed)]
return [str(value)]
def _serialize_person(row: Any) -> dict[str, Any]:
return {
"person_id": str(row["person_id"]),
@@ -24,7 +47,7 @@ def _serialize_person(row: Any) -> dict[str, Any]:
"primary_email": row["primary_email"],
"primary_phone": row["primary_phone"],
"buyer_type": row["buyer_type"],
"persona_labels": row["persona_labels"] or [],
"persona_labels": _json_string_list(row["persona_labels"]),
"source_confidence": float(row["source_confidence"] or 0.0),
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
}
@@ -38,8 +61,8 @@ def _serialize_lead(row: Any) -> dict[str, Any]:
"urgency": row["urgency"],
"financing_posture": row["financing_posture"],
"timeline_to_decision": row["timeline_to_decision"],
"objections": row["objections"] or [],
"motivations": row["motivations"] or [],
"objections": _json_string_list(row["objections"]),
"motivations": _json_string_list(row["motivations"]),
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
}
@@ -99,7 +122,7 @@ def _serialize_property_interest(row: Any) -> dict[str, Any]:
}
async def get_client_360(conn: Any, person_id: str) -> dict[str, Any] | None:
async def get_client_360(conn: Any, tenant_id: str, person_id: str) -> dict[str, Any] | None:
"""
Aggregate a full Client360Snapshot for a given person_id.
This is a read model — derived from canonical tables, never primary truth.
@@ -111,8 +134,10 @@ async def get_client_360(conn: Any, person_id: str) -> dict[str, Any] | None:
buyer_type, persona_labels, source_confidence, created_at
FROM crm_people
WHERE person_id = $1::uuid
AND tenant_id = $2
""",
person_id,
tenant_id,
)
if not person_row:
return None
@@ -126,9 +151,12 @@ async def get_client_360(conn: Any, person_id: str) -> dict[str, Any] | None:
FROM crm_accounts ca
INNER JOIN crm_leads cl ON cl.account_id = ca.account_id
WHERE cl.person_id = $1::uuid
AND cl.tenant_id = $2
AND ca.tenant_id = $2
LIMIT 5
""",
person_id,
tenant_id,
)
account_links = [
{
@@ -147,10 +175,12 @@ async def get_client_360(conn: Any, person_id: str) -> dict[str, Any] | None:
timeline_to_decision, objections, motivations, created_at
FROM crm_leads
WHERE person_id = $1::uuid
AND tenant_id = $2
ORDER BY created_at DESC
LIMIT 1
""",
person_id,
tenant_id,
)
lead = _serialize_lead(lead_row) if lead_row else None
@@ -162,10 +192,13 @@ async def get_client_360(conn: Any, person_id: str) -> dict[str, Any] | None:
FROM crm_opportunities co
INNER JOIN crm_leads cl ON cl.lead_id = co.lead_id
WHERE cl.person_id = $1::uuid
AND cl.tenant_id = $2
AND co.tenant_id = $2
ORDER BY co.updated_at DESC
LIMIT 5
""",
person_id,
tenant_id,
)
active_opportunities = [_serialize_opportunity(r) for r in opp_rows]
@@ -175,10 +208,12 @@ async def get_client_360(conn: Any, person_id: str) -> dict[str, Any] | None:
SELECT interaction_id, channel, interaction_type, happened_at, summary
FROM intel_interactions
WHERE person_id = $1::uuid
AND tenant_id = $2
ORDER BY happened_at DESC
LIMIT 10
""",
person_id,
tenant_id,
)
recent_interactions = [_serialize_interaction(r) for r in interaction_rows]
@@ -189,10 +224,12 @@ async def get_client_360(conn: Any, person_id: str) -> dict[str, Any] | None:
budget_min, budget_max, priority
FROM crm_property_interests
WHERE person_id = $1::uuid
AND tenant_id = $2
ORDER BY priority ASC, interest_id ASC
LIMIT 10
""",
person_id,
tenant_id,
)
property_interests = [_serialize_property_interest(r) for r in interest_rows]
@@ -202,11 +239,13 @@ async def get_client_360(conn: Any, person_id: str) -> dict[str, Any] | None:
SELECT reminder_id, reminder_type, title, due_at, status, priority
FROM intel_reminders
WHERE person_id = $1::uuid
AND tenant_id = $2
AND status IN ('pending', 'snoozed')
ORDER BY due_at ASC NULLS LAST
LIMIT 10
""",
person_id,
tenant_id,
)
tasks = [_serialize_reminder(r) for r in task_rows]
@@ -216,8 +255,10 @@ async def get_client_360(conn: Any, person_id: str) -> dict[str, Any] | None:
SELECT score_type, current_value, computed_at, reasoning
FROM intel_qd_scores
WHERE person_id = $1::uuid
AND tenant_id = $2
""",
person_id,
tenant_id,
)
qd_overview = {r["score_type"]: _serialize_qd_score(r) for r in qd_rows}
@@ -262,6 +303,7 @@ async def get_client_360(conn: Any, person_id: str) -> dict[str, Any] | None:
async def get_contact_list(
conn: Any,
tenant_id: str,
search: str | None = None,
buyer_type: str | None = None,
status: str | None = None,
@@ -272,8 +314,8 @@ async def get_contact_list(
Paginated contact list with lead status and QD summary.
Implements the 'summary query' pattern from Doc 09.
"""
clauses: list[str] = ["1=1"]
params: list[Any] = []
clauses: list[str] = ["p.tenant_id = $1"]
params: list[Any] = [tenant_id]
if search:
params.append(f"%{search}%")
@@ -310,14 +352,15 @@ async def get_contact_list(
COALESCE(qs.intent_value, 0.0) AS intent_score,
COALESCE(qs.engagement_value, qs.intent_value, 0.0) AS engagement_score,
COALESCE(qs.urgency_value, 0.0) AS urgency_score,
(SELECT COUNT(*) FROM intel_interactions ii WHERE ii.person_id = p.person_id) AS interaction_count,
(SELECT MAX(happened_at) FROM intel_interactions ii WHERE ii.person_id = p.person_id) AS last_interaction_at,
(SELECT COUNT(*) FROM intel_reminders ir WHERE ir.person_id = p.person_id AND ir.status = 'pending') AS pending_tasks
(SELECT COUNT(*) FROM intel_interactions ii WHERE ii.person_id = p.person_id AND ii.tenant_id = p.tenant_id) AS interaction_count,
(SELECT MAX(happened_at) FROM intel_interactions ii WHERE ii.person_id = p.person_id AND ii.tenant_id = p.tenant_id) AS last_interaction_at,
(SELECT COUNT(*) FROM intel_reminders ir WHERE ir.person_id = p.person_id AND ir.tenant_id = p.tenant_id AND ir.status = 'pending') AS pending_tasks
FROM crm_people p
LEFT JOIN LATERAL (
SELECT lead_id, status, budget_band, urgency
FROM crm_leads
WHERE person_id = p.person_id
AND tenant_id = p.tenant_id
ORDER BY created_at DESC
LIMIT 1
) cl ON TRUE
@@ -325,6 +368,7 @@ async def get_contact_list(
SELECT project_name
FROM crm_property_interests
WHERE person_id = p.person_id
AND tenant_id = p.tenant_id
ORDER BY priority ASC, created_at DESC
LIMIT 1
) pi ON TRUE
@@ -335,6 +379,7 @@ async def get_contact_list(
MAX(CASE WHEN score_type = 'urgency_score' THEN current_value END) AS urgency_value
FROM intel_qd_scores
WHERE person_id = p.person_id
AND tenant_id = p.tenant_id
) qs ON TRUE
{where}
ORDER BY last_interaction_at DESC NULLS LAST, p.created_at DESC
@@ -344,7 +389,7 @@ async def get_contact_list(
count_query = f"""
SELECT COUNT(*)
FROM crm_people p
LEFT JOIN crm_leads cl ON cl.person_id = p.person_id
LEFT JOIN crm_leads cl ON cl.person_id = p.person_id AND cl.tenant_id = p.tenant_id
{where}
"""

View File

@@ -202,6 +202,7 @@ def create_import_batch_record(
mapping_manifest: dict[str, Any],
source_system: str = "csv_upload",
uploaded_by_id: str | None = None,
tenant_id: str | None = None,
) -> dict[str, Any]:
"""
Build the workflow_import_batches record payload.
@@ -216,6 +217,7 @@ def create_import_batch_record(
"mapped_count": mapping_manifest.get("mapped_count", 0),
"unresolved_count": mapping_manifest.get("unmapped_count", 0),
"uploaded_by": uploaded_by_id,
"tenant_id": tenant_id,
"lifecycle": "parsed",
"mapping_manifest": mapping_manifest,
"created_at": now,
@@ -230,15 +232,16 @@ async def persist_import_batch(conn: Any, batch: dict[str, Any]) -> str:
await conn.execute(
"""
INSERT INTO workflow_import_batches (
batch_id, source_system, uploaded_filename, mime_type, row_count,
batch_id, tenant_id, source_system, uploaded_filename, mime_type, row_count,
mapped_count, unresolved_count, uploaded_by, lifecycle, mapping_manifest,
created_at, updated_at
) VALUES (
$1::uuid, $2, $3, $4, $5, $6, $7,
$8::uuid, $9::import_lifecycle, $10::jsonb, NOW(), NOW()
$1::uuid, $2, $3, $4, $5, $6, $7, $8,
$9::uuid, $10::import_lifecycle, $11::jsonb, NOW(), NOW()
)
""",
batch["batch_id"],
batch["tenant_id"],
batch["source_system"],
batch.get("uploaded_filename", "unknown.csv"),
batch.get("mime_type", "text/csv"),
@@ -253,7 +256,7 @@ async def persist_import_batch(conn: Any, batch: dict[str, Any]) -> str:
async def persist_proposals_as_workflow_actions(
conn: Any, proposals: list[dict[str, Any]]
conn: Any, proposals: list[dict[str, Any]], tenant_id: str
) -> int:
"""
Insert proposals into workflow_actions table for human review.
@@ -264,15 +267,16 @@ async def persist_proposals_as_workflow_actions(
await conn.execute(
"""
INSERT INTO workflow_actions (
action_id, action_type, target_domain, proposal_payload,
action_id, tenant_id, action_type, target_domain, proposal_payload,
reasoning_summary, confidence, status, approval_required,
created_by_agent, created_at, updated_at
) VALUES (
$1::uuid, 'import_proposal', 'crm', $2::jsonb,
$3, $4, 'pending'::wf_status, $5, 'ingest_service', NOW(), NOW()
$1::uuid, $2, 'import_proposal', 'crm', $3::jsonb,
$4, $5, 'pending'::wf_status, $6, 'ingest_service', NOW(), NOW()
)
""",
p["proposal_id"],
tenant_id,
json.dumps(p),
f"Import row {p['row_number']}: {p['canonical_payload'].get('full_name', 'unknown')}",
p["confidence"],

View File

@@ -0,0 +1,212 @@
from __future__ import annotations
import asyncio
import os
from contextlib import asynccontextmanager
from typing import Any
from jose import jwt
os.environ.setdefault("VELOCITY_JWT_SECRET", "test-secret")
from backend.auth.dependencies import UserPrincipal
from backend.auth import service as auth_service
auth_service.verify_password = lambda plain, hashed: plain == hashed
class AppState:
def __init__(self, pool: Any) -> None:
self.db_pool = pool
self._auth_user_directory_schema_ready = False
class AppStub:
def __init__(self, pool: Any) -> None:
self.state = AppState(pool)
class RequestStub:
def __init__(self, pool: Any) -> None:
self.app = AppStub(pool)
class FakeConn:
def __init__(self) -> None:
password_hash = "velocity-demo-password"
self.users: dict[str, dict[str, Any]] = {
"user-alpha": {
"id": "00000000-0000-0000-0000-000000000001",
"email": "alpha@example.com",
"password_hash": password_hash,
"role": "ADMIN",
"tenant_id": "tenant_alpha",
"full_name": "Alpha Operator",
"avatar_url": "/assets/profile_avatars/alpha.png",
"is_active": True,
},
"user-beta": {
"id": "00000000-0000-0000-0000-000000000002",
"email": "beta@example.com",
"password_hash": password_hash,
"role": "ADMIN",
"tenant_id": "tenant_beta",
"full_name": "Beta Operator",
"avatar_url": "/assets/profile_avatars/beta.png",
"is_active": True,
},
"user-legacy": {
"id": "00000000-0000-0000-0000-000000000003",
"email": "legacy@example.com",
"password_hash": password_hash,
"role": "SENIOR_BROKER",
"tenant_id": "",
"full_name": "Legacy Tenant User",
"avatar_url": None,
"is_active": True,
},
}
self.schema_ready = False
async def execute(self, query: str, *args):
normalized = " ".join(query.strip().split())
if normalized.startswith("ALTER TABLE users_and_roles ADD COLUMN IF NOT EXISTS tenant_id TEXT"):
for user in self.users.values():
user.setdefault("tenant_id", "")
return "ALTER TABLE"
if normalized.startswith("UPDATE users_and_roles SET tenant_id = $1 WHERE tenant_id IS NULL OR tenant_id = ''"):
for user in self.users.values():
if not user.get("tenant_id"):
user["tenant_id"] = args[0]
return "UPDATE"
if normalized.startswith("ALTER TABLE users_and_roles ALTER COLUMN tenant_id SET DEFAULT"):
return "ALTER TABLE"
if normalized.startswith("ALTER TABLE users_and_roles ALTER COLUMN tenant_id SET NOT NULL"):
self.schema_ready = True
return "ALTER TABLE"
if normalized.startswith("CREATE INDEX IF NOT EXISTS idx_users_tenant_active ON users_and_roles (tenant_id, is_active)"):
return "CREATE INDEX"
if normalized.startswith("UPDATE users_and_roles SET avatar_url = $2 WHERE id = $1::uuid AND tenant_id = $3"):
for user in self.users.values():
if user["id"] == args[0] and user["tenant_id"] == args[2]:
user["avatar_url"] = args[1]
return "UPDATE 1"
return "UPDATE 0"
raise AssertionError(f"Unexpected execute query: {query}")
async def fetchrow(self, query: str, *args):
normalized = " ".join(query.strip().split())
if "FROM users_and_roles" not in normalized:
raise AssertionError(f"Unexpected fetchrow query: {query}")
if "password_hash" in normalized:
email, tenant_fallback = args
for user in self.users.values():
if user["email"] == email and user["is_active"]:
return {
"id": user["id"],
"role": user["role"],
"password_hash": user["password_hash"],
"tenant_id": user["tenant_id"] or tenant_fallback,
}
return None
if "WHERE id = $1::uuid AND COALESCE(NULLIF(tenant_id, ''), $2) = $2" in normalized:
user_id, tenant_id = args
for user in self.users.values():
resolved_tenant = user["tenant_id"] or tenant_id
if user["id"] == user_id and resolved_tenant == tenant_id:
return {
"full_name": user["full_name"],
"email": user["email"],
"avatar_url": user["avatar_url"],
"tenant_id": resolved_tenant,
}
return None
raise AssertionError(f"Unexpected fetchrow query: {query}")
async def fetch(self, query: str, *args):
normalized = " ".join(query.strip().split())
if "FROM users_and_roles" not in normalized:
raise AssertionError(f"Unexpected fetch query: {query}")
tenant_fallback, tenant_id = args
rows = []
for user in self.users.values():
resolved_tenant = user["tenant_id"] or tenant_fallback
if user["is_active"] and resolved_tenant == tenant_id:
rows.append(
{
"user_id": user["id"],
"role": user["role"],
"tenant_id": resolved_tenant,
"full_name": user["full_name"],
"email": user["email"],
"avatar_url": user["avatar_url"],
}
)
rows.sort(key=lambda row: (row["full_name"] or row["email"] or row["user_id"]))
return rows
class FakePool:
def __init__(self) -> None:
self.conn = FakeConn()
@asynccontextmanager
async def acquire(self):
yield self.conn
def _build_request() -> tuple[RequestStub, FakePool]:
pool = FakePool()
return RequestStub(pool), pool
def test_login_mints_token_with_user_tenant_id() -> None:
request, pool = _build_request()
response = asyncio.run(
auth_service.login_with_directory(
app=request.app,
email="alpha@example.com",
password="velocity-demo-password",
)
)
payload = jwt.get_unverified_claims(response["access_token"])
assert payload["tenant_id"] == "tenant_alpha"
assert pool.conn.schema_ready is True
def test_login_backfills_legacy_users_to_default_tenant_before_minting() -> None:
request, pool = _build_request()
response = asyncio.run(
auth_service.login_with_directory(
app=request.app,
email="legacy@example.com",
password="velocity-demo-password",
)
)
payload = jwt.get_unverified_claims(response["access_token"])
assert payload["tenant_id"] == "tenant_velocity"
assert pool.conn.users["user-legacy"]["tenant_id"] == "tenant_velocity"
def test_auth_me_returns_profile_for_authenticated_tenant() -> None:
request, _pool = _build_request()
user = UserPrincipal("00000000-0000-0000-0000-000000000001", "ADMIN", "tenant_alpha")
response = asyncio.run(auth_service.read_authenticated_user_profile(app=request.app, user=user))
assert response["tenant_id"] == "tenant_alpha"
assert response["email"] == "alpha@example.com"
def test_auth_users_are_scoped_to_authenticated_tenant() -> None:
request, _pool = _build_request()
user = UserPrincipal("00000000-0000-0000-0000-000000000001", "ADMIN", "tenant_alpha")
users = asyncio.run(auth_service.list_tenant_users(app=request.app, user=user))
assert [user["email"] for user in users] == ["alpha@example.com"]
assert all(user["tenant_id"] == "tenant_alpha" for user in users)

View File

@@ -0,0 +1,162 @@
from __future__ import annotations
import io
import os
from contextlib import asynccontextmanager
from typing import Any
from fastapi import FastAPI
from fastapi.testclient import TestClient
os.environ.setdefault("VELOCITY_JWT_SECRET", "test-secret")
from backend.api import routes_crm_imports
from backend.auth.dependencies import UserPrincipal, get_current_user
class FakeConn:
async def execute(self, query: str, *args):
return "OK"
class FakePool:
def __init__(self) -> None:
self.conn = FakeConn()
@asynccontextmanager
async def acquire(self):
yield self.conn
def _build_app(*, authenticated: bool) -> TestClient:
app = FastAPI()
app.state.db_pool = FakePool()
app.include_router(routes_crm_imports.router, prefix="/api")
if authenticated:
app.dependency_overrides[get_current_user] = lambda: UserPrincipal(
"00000000-0000-0000-0000-000000000001",
"ADMIN",
"tenant_alpha",
)
return TestClient(app)
def test_canonical_crm_routes_require_authentication() -> None:
client = _build_app(authenticated=False)
response = client.get("/api/crm/contacts")
assert response.status_code == 401
assert response.json()["detail"] == "Missing or malformed Authorization header."
def test_canonical_crm_task_routes_require_authentication() -> None:
client = _build_app(authenticated=False)
response = client.get("/api/crm/tasks")
assert response.status_code == 401
assert response.json()["detail"] == "Missing or malformed Authorization header."
def test_canonical_crm_task_write_routes_require_authentication() -> None:
client = _build_app(authenticated=False)
response = client.patch(
"/api/crm/tasks/33333333-3333-3333-3333-333333333333",
json={"status": "done"},
)
assert response.status_code == 401
assert response.json()["detail"] == "Missing or malformed Authorization header."
def test_canonical_crm_lead_stage_write_routes_require_authentication() -> None:
client = _build_app(authenticated=False)
response = client.patch(
"/api/crm/leads/22222222-2222-2222-2222-222222222222/stage",
json={"status": "qualified"},
)
assert response.status_code == 401
assert response.json()["detail"] == "Missing or malformed Authorization header."
def test_canonical_crm_opportunity_write_routes_require_authentication() -> None:
client = _build_app(authenticated=False)
response = client.patch(
"/api/crm/opportunities/55555555-5555-5555-5555-555555555555",
json={"stage": "negotiation"},
)
assert response.status_code == 401
assert response.json()["detail"] == "Missing or malformed Authorization header."
def test_canonical_crm_import_upload_requires_authentication() -> None:
client = _build_app(authenticated=False)
response = client.post(
"/api/crm/imports",
params={"source_system": "csv_upload"},
files={"file": ("contacts.csv", io.BytesIO(b"name,phone\nAmina,+9715000\n"), "text/csv")},
)
assert response.status_code == 401
assert response.json()["detail"] == "Missing or malformed Authorization header."
def test_canonical_crm_contacts_can_be_read_when_authenticated(monkeypatch) -> None:
client = _build_app(authenticated=True)
async def fake_get_contact_list(
conn: Any,
tenant_id: str,
search: str | None = None,
buyer_type: str | None = None,
status: str | None = None,
limit: int = 50,
offset: int = 0,
) -> dict[str, Any]:
assert tenant_id == "tenant_alpha"
assert search is None
assert buyer_type is None
assert status is None
return {
"contacts": [
{
"person_id": "11111111-1111-1111-1111-111111111111",
"full_name": "Amina Rahman",
"primary_email": "amina@example.com",
"primary_phone": "+971500000001",
"buyer_type": "high_intent",
"lead_id": "22222222-2222-2222-2222-222222222222",
"legacy_li_id": None,
"lead_status": "qualified",
"budget_band": "AED 12M",
"urgency": "high",
"primary_interest": "Marina Penthouse",
"intent_score": 0.94,
"engagement_score": 0.91,
"urgency_score": 0.88,
"interaction_count": 6,
"last_interaction_at": "2026-04-22T10:00:00+00:00",
"pending_tasks": 1,
"created_at": "2026-04-21T10:00:00+00:00",
}
],
"total": 1,
"limit": limit,
"offset": offset,
}
monkeypatch.setattr(routes_crm_imports, "get_contact_list", fake_get_contact_list)
response = client.get("/api/crm/contacts")
assert response.status_code == 200
payload = response.json()["data"]
assert payload["total"] == 1
assert payload["contacts"][0]["full_name"] == "Amina Rahman"

View File

@@ -0,0 +1,517 @@
from __future__ import annotations
import os
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from typing import Any
from fastapi import FastAPI
from fastapi.testclient import TestClient
os.environ.setdefault("VELOCITY_JWT_SECRET", "test-secret")
from backend.api import routes_crm_imports
from backend.auth.dependencies import UserPrincipal, get_current_user
def _now() -> datetime:
return datetime.now(timezone.utc)
class FakeConn:
def __init__(self) -> None:
self.people: dict[str, dict[str, Any]] = {
"11111111-1111-1111-1111-111111111111": {
"person_id": "11111111-1111-1111-1111-111111111111",
"tenant_id": "tenant_alpha",
"full_name": "Amina Rahman",
"primary_email": "amina@example.com",
"primary_phone": "+971500000001",
"secondary_phone": None,
"buyer_type": "high_intent",
"persona_labels": [],
"source_confidence": 1.0,
"created_at": _now(),
"updated_at": _now(),
}
}
self.leads: dict[str, dict[str, Any]] = {
"22222222-2222-2222-2222-222222222222": {
"lead_id": "22222222-2222-2222-2222-222222222222",
"tenant_id": "tenant_alpha",
"person_id": "11111111-1111-1111-1111-111111111111",
"status": "new",
"budget_band": "AED 12M",
"urgency": "high",
}
}
self.reminders: dict[str, dict[str, Any]] = {
"33333333-3333-3333-3333-333333333333": {
"reminder_id": "33333333-3333-3333-3333-333333333333",
"tenant_id": "tenant_alpha",
"person_id": "11111111-1111-1111-1111-111111111111",
"lead_id": "22222222-2222-2222-2222-222222222222",
"reminder_type": "follow_up",
"title": "Call marina lead",
"notes": "Confirm visit time",
"status": "pending",
"priority": "high",
"due_at": _now(),
},
"44444444-4444-4444-4444-444444444444": {
"reminder_id": "44444444-4444-4444-4444-444444444444",
"tenant_id": "tenant_beta",
"person_id": "99999999-9999-9999-9999-999999999999",
"lead_id": None,
"reminder_type": "follow_up",
"title": "Cross-tenant task",
"notes": "Should not leak",
"status": "pending",
"priority": "normal",
"due_at": _now(),
},
}
self.opportunities: dict[str, dict[str, Any]] = {
"55555555-5555-5555-5555-555555555555": {
"opportunity_id": "55555555-5555-5555-5555-555555555555",
"tenant_id": "tenant_alpha",
"lead_id": "22222222-2222-2222-2222-222222222222",
"stage": "proposal",
"value": 12000000.0,
"probability": 60,
"expected_close_date": None,
"next_action": "Share proposal",
"notes": "Initial terms shared",
"project_name": "Marina Residences",
},
"66666666-6666-6666-6666-666666666666": {
"opportunity_id": "66666666-6666-6666-6666-666666666666",
"tenant_id": "tenant_beta",
"lead_id": "99999999-9999-9999-9999-999999999999",
"stage": "proposal",
"value": 7000000.0,
"probability": 50,
"expected_close_date": None,
"next_action": "Cross tenant",
"notes": None,
"project_name": None,
},
}
self.stage_history: list[dict[str, Any]] = []
async def execute(self, query: str, *args):
normalized = " ".join(query.strip().split())
if normalized.startswith("ALTER TABLE ") or normalized.startswith("CREATE INDEX IF NOT EXISTS "):
return "OK"
if normalized.startswith("UPDATE ") and " SET tenant_id = $1 " in f" {normalized} ":
return "UPDATE"
if normalized.startswith("INSERT INTO crm_people"):
self.people[args[0]] = {
"person_id": args[0],
"tenant_id": args[1],
"full_name": args[2],
"primary_email": args[3],
"primary_phone": args[4],
"secondary_phone": None,
"buyer_type": args[5],
"persona_labels": [],
"source_confidence": 1.0,
"created_at": _now(),
"updated_at": _now(),
}
return "INSERT 1"
if normalized.startswith("INSERT INTO crm_leads"):
self.leads[args[0]] = {
"lead_id": args[0],
"tenant_id": args[1],
"person_id": args[2],
}
return "INSERT 1"
if normalized.startswith("INSERT INTO crm_property_interests"):
return "INSERT 1"
if normalized.startswith("INSERT INTO intel_reminders"):
self.reminders[args[0]] = {
"reminder_id": args[0],
"tenant_id": args[1],
"person_id": args[2],
"lead_id": args[3],
"reminder_type": args[4],
"title": args[5],
"notes": args[6],
"due_at": args[7],
"status": "pending",
"priority": args[8],
}
return "INSERT 1"
if normalized.startswith("INSERT INTO crm_stage_history"):
self.stage_history.append(
{
"history_id": args[0],
"lead_id": args[1],
"from_status": args[2],
"to_status": args[3],
"changed_by": args[4],
"notes": args[5],
}
)
return "INSERT 1"
raise AssertionError(f"Unexpected execute query: {query}")
async def fetchrow(self, query: str, *args):
normalized = " ".join(query.strip().split())
if "FROM crm_people" in normalized and "WHERE person_id = $1::uuid AND tenant_id = $2" in normalized:
row = self.people.get(args[0])
return dict(row) if row and row["tenant_id"] == args[1] else None
if "FROM crm_leads" in normalized and "WHERE lead_id = $1::uuid AND person_id = $2::uuid AND tenant_id = $3" in normalized:
row = self.leads.get(args[0])
return dict(row) if row and row["person_id"] == args[1] and row["tenant_id"] == args[2] else None
if "FROM intel_reminders" in normalized and "WHERE reminder_id = $1::uuid AND tenant_id = $2" in normalized:
row = self.reminders.get(args[0])
return dict(row) if row and row["tenant_id"] == args[1] else None
if normalized.startswith("UPDATE intel_reminders ir SET status ="):
row = self.reminders.get(args[0])
if not row or row["tenant_id"] != args[1]:
return None
row["status"] = args[2]
if args[3] is not None:
row["due_at"] = args[3]
if args[4] is not None:
row["notes"] = args[4]
person = self.people[row["person_id"]]
return {
"reminder_id": row["reminder_id"],
"reminder_type": row["reminder_type"],
"title": row["title"],
"notes": row["notes"],
"due_at": row["due_at"],
"status": row["status"],
"priority": row["priority"],
"person_id": person["person_id"],
"full_name": person["full_name"],
"primary_phone": person["primary_phone"],
}
if "FROM crm_leads cl" in normalized and "WHERE cl.lead_id = $1::uuid AND cl.tenant_id = $2" in normalized:
row = self.leads.get(args[0])
if not row or row["tenant_id"] != args[1]:
return None
person = self.people[row["person_id"]]
return {
"lead_id": row["lead_id"],
"person_id": row["person_id"],
"status": row["status"],
"budget_band": row["budget_band"],
"urgency": row["urgency"],
"full_name": person["full_name"],
"primary_phone": person["primary_phone"],
}
if normalized.startswith("UPDATE crm_leads SET status = $3::crm_lead_status, updated_at = NOW()"):
row = self.leads.get(args[0])
if not row or row["tenant_id"] != args[1]:
return None
row["status"] = args[2]
return {
"lead_id": row["lead_id"],
"person_id": row["person_id"],
"status": row["status"],
"budget_band": row["budget_band"],
"urgency": row["urgency"],
}
if "FROM crm_opportunities co" in normalized and "WHERE co.opportunity_id = $1::uuid AND co.tenant_id = $2" in normalized:
row = self.opportunities.get(args[0])
if not row or row["tenant_id"] != args[1]:
return None
lead = self.leads[row["lead_id"]]
person = self.people[lead["person_id"]]
return {
"opportunity_id": row["opportunity_id"],
"stage": row["stage"],
"value": row["value"],
"probability": row["probability"],
"expected_close_date": row["expected_close_date"],
"next_action": row["next_action"],
"notes": row["notes"],
"person_id": person["person_id"],
"full_name": person["full_name"],
"primary_phone": person["primary_phone"],
"project_name": row["project_name"],
}
if normalized.startswith("WITH updated AS ( UPDATE crm_opportunities co SET stage ="):
row = self.opportunities.get(args[0])
if not row or row["tenant_id"] != args[1]:
return None
if args[2] is not None:
row["stage"] = args[2]
if args[3]:
row["value"] = args[4]
if args[5]:
row["probability"] = args[6]
if args[7]:
row["expected_close_date"] = args[8]
if args[9]:
row["next_action"] = args[10]
if args[11]:
row["notes"] = args[12]
lead = self.leads[row["lead_id"]]
person = self.people[lead["person_id"]]
return {
"opportunity_id": row["opportunity_id"],
"stage": row["stage"],
"value": row["value"],
"probability": row["probability"],
"expected_close_date": row["expected_close_date"],
"next_action": row["next_action"],
"notes": row["notes"],
"person_id": person["person_id"],
"full_name": person["full_name"],
"primary_phone": person["primary_phone"],
"project_name": row["project_name"],
}
raise AssertionError(f"Unexpected fetchrow query: {query}")
async def fetch(self, query: str, *args):
normalized = " ".join(query.strip().split())
if "FROM intel_reminders ir" in normalized:
tenant_id = args[0]
status_filter = args[1] if len(args) >= 3 else None
rows = []
for reminder in self.reminders.values():
if reminder["tenant_id"] != tenant_id:
continue
if status_filter and reminder["status"] != status_filter:
continue
person = self.people.get(reminder["person_id"])
if not person or person["tenant_id"] != tenant_id:
continue
rows.append(
{
"reminder_id": reminder["reminder_id"],
"reminder_type": reminder["reminder_type"],
"title": reminder["title"],
"notes": reminder["notes"],
"due_at": reminder["due_at"],
"status": reminder["status"],
"priority": reminder["priority"],
"person_id": person["person_id"],
"full_name": person["full_name"],
"primary_phone": person["primary_phone"],
}
)
return rows
raise AssertionError(f"Unexpected fetch query: {query}")
class FakePool:
def __init__(self) -> None:
self.conn = FakeConn()
@asynccontextmanager
async def acquire(self):
yield self.conn
def _build_shared_app(pool: FakePool, tenant_id: str) -> TestClient:
app = FastAPI()
app.state.db_pool = pool
app.include_router(routes_crm_imports.router, prefix="/api")
app.dependency_overrides[get_current_user] = lambda: UserPrincipal(
"00000000-0000-0000-0000-000000000001",
"ADMIN",
tenant_id,
)
return TestClient(app)
def test_canonical_contact_list_receives_authenticated_tenant(monkeypatch) -> None:
pool = FakePool()
client = _build_shared_app(pool, "tenant_alpha")
async def fake_get_contact_list(
conn: Any,
tenant_id: str,
search: str | None = None,
buyer_type: str | None = None,
status: str | None = None,
limit: int = 50,
offset: int = 0,
) -> dict[str, Any]:
assert tenant_id == "tenant_alpha"
return {"contacts": [], "total": 0, "limit": limit, "offset": offset}
monkeypatch.setattr(routes_crm_imports, "get_contact_list", fake_get_contact_list)
response = client.get("/api/crm/contacts")
assert response.status_code == 200
assert response.json()["data"]["total"] == 0
def test_canonical_task_routes_are_scoped_to_authenticated_tenant() -> None:
pool = FakePool()
tenant_alpha = _build_shared_app(pool, "tenant_alpha")
tenant_beta = _build_shared_app(pool, "tenant_beta")
alpha_response = tenant_alpha.get("/api/crm/tasks")
beta_response = tenant_beta.get("/api/crm/tasks")
assert alpha_response.status_code == 200
assert len(alpha_response.json()["data"]) == 1
assert alpha_response.json()["data"][0]["title"] == "Call marina lead"
assert beta_response.status_code == 200
assert beta_response.json()["data"] == []
def test_create_contact_persists_authenticated_tenant_on_canonical_records() -> None:
pool = FakePool()
client = _build_shared_app(pool, "tenant_alpha")
response = client.post(
"/api/crm/contacts",
json={
"full_name": "New Canonical Contact",
"primary_phone": "+971500000010",
"budget_band": "AED 8M",
"project_name": "Skyline",
},
)
assert response.status_code == 201
person_id = response.json()["data"]["person_id"]
created_person = pool.conn.people[person_id]
assert created_person["tenant_id"] == "tenant_alpha"
assert any(lead["tenant_id"] == "tenant_alpha" and lead["person_id"] == person_id for lead in pool.conn.leads.values())
def test_create_task_rejects_cross_tenant_person_reference() -> None:
pool = FakePool()
client = _build_shared_app(pool, "tenant_beta")
response = client.post(
"/api/crm/tasks",
json={
"person_id": "11111111-1111-1111-1111-111111111111",
"lead_id": "22222222-2222-2222-2222-222222222222",
"title": "Cross tenant task",
},
)
assert response.status_code == 404
assert response.json()["detail"] == "Contact '11111111-1111-1111-1111-111111111111' not found."
def test_update_task_marks_done_for_authenticated_tenant() -> None:
pool = FakePool()
client = _build_shared_app(pool, "tenant_alpha")
response = client.patch(
"/api/crm/tasks/33333333-3333-3333-3333-333333333333",
json={"status": "done"},
)
assert response.status_code == 200
payload = response.json()
assert payload["data"]["status"] == "done"
assert payload["meta"]["previous_status"] == "pending"
assert payload["meta"]["changed"] is True
assert pool.conn.reminders["33333333-3333-3333-3333-333333333333"]["status"] == "done"
def test_update_task_marks_confirmed_for_authenticated_tenant() -> None:
pool = FakePool()
client = _build_shared_app(pool, "tenant_alpha")
response = client.patch(
"/api/crm/tasks/33333333-3333-3333-3333-333333333333",
json={"status": "confirmed"},
)
assert response.status_code == 200
payload = response.json()
assert payload["data"]["status"] == "confirmed"
assert payload["meta"]["previous_status"] == "pending"
assert payload["meta"]["changed"] is True
assert pool.conn.reminders["33333333-3333-3333-3333-333333333333"]["status"] == "confirmed"
def test_update_task_rejects_cross_tenant_task_reference() -> None:
pool = FakePool()
client = _build_shared_app(pool, "tenant_beta")
response = client.patch(
"/api/crm/tasks/33333333-3333-3333-3333-333333333333",
json={"status": "done"},
)
assert response.status_code == 404
assert response.json()["detail"] == "Task '33333333-3333-3333-3333-333333333333' not found."
def test_update_lead_stage_records_canonical_stage_history() -> None:
pool = FakePool()
client = _build_shared_app(pool, "tenant_alpha")
response = client.patch(
"/api/crm/leads/22222222-2222-2222-2222-222222222222/stage",
json={"status": "qualified", "notes": "Advanced from iPad Oracle pipeline."},
)
assert response.status_code == 200
payload = response.json()
assert payload["data"]["status"] == "qualified"
assert payload["meta"]["previous_status"] == "new"
assert payload["meta"]["changed"] is True
assert pool.conn.leads["22222222-2222-2222-2222-222222222222"]["status"] == "qualified"
assert len(pool.conn.stage_history) == 1
assert pool.conn.stage_history[0]["from_status"] == "new"
assert pool.conn.stage_history[0]["to_status"] == "qualified"
def test_update_lead_stage_rejects_cross_tenant_reference() -> None:
pool = FakePool()
client = _build_shared_app(pool, "tenant_beta")
response = client.patch(
"/api/crm/leads/22222222-2222-2222-2222-222222222222/stage",
json={"status": "qualified"},
)
assert response.status_code == 404
assert response.json()["detail"] == "Lead '22222222-2222-2222-2222-222222222222' not found."
def test_update_opportunity_mutates_canonical_deal_for_authenticated_tenant() -> None:
pool = FakePool()
client = _build_shared_app(pool, "tenant_alpha")
response = client.patch(
"/api/crm/opportunities/55555555-5555-5555-5555-555555555555",
json={
"stage": "negotiation",
"probability": 75,
"next_action": "Schedule commercial review",
},
)
assert response.status_code == 200
payload = response.json()
assert payload["data"]["stage"] == "negotiation"
assert payload["data"]["probability"] == 75
assert payload["data"]["next_action"] == "Schedule commercial review"
assert payload["data"]["client_name"] == "Amina Rahman"
assert payload["meta"]["previous_stage"] == "proposal"
assert payload["meta"]["changed"] is True
assert pool.conn.opportunities["55555555-5555-5555-5555-555555555555"]["stage"] == "negotiation"
def test_update_opportunity_rejects_cross_tenant_reference() -> None:
pool = FakePool()
client = _build_shared_app(pool, "tenant_beta")
response = client.patch(
"/api/crm/opportunities/55555555-5555-5555-5555-555555555555",
json={"stage": "negotiation"},
)
assert response.status_code == 404
assert response.json()["detail"] == "Opportunity '55555555-5555-5555-5555-555555555555' not found."

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import os
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from typing import Any
@@ -7,7 +8,10 @@ from typing import Any
from fastapi import FastAPI
from fastapi.testclient import TestClient
os.environ.setdefault("VELOCITY_JWT_SECRET", "test-secret")
from backend.api.routes_crm import analytics_router, crm_router
from backend.auth.dependencies import UserPrincipal, get_current_user
def _now() -> datetime:
@@ -23,8 +27,36 @@ class FakeConn:
normalized = query.strip()
if "CREATE TABLE IF NOT EXISTS leads" in normalized or "CREATE TABLE IF NOT EXISTS chat_logs" in normalized:
return "CREATE"
if normalized.startswith("ALTER TABLE leads ADD COLUMN IF NOT EXISTS tenant_id"):
for lead in self.leads.values():
lead.setdefault("tenant_id", "tenant_velocity")
return "ALTER TABLE"
if normalized.startswith("ALTER TABLE chat_logs ADD COLUMN IF NOT EXISTS tenant_id"):
for log in self.chat_logs.values():
log.setdefault("tenant_id", "tenant_velocity")
return "ALTER TABLE"
if normalized.startswith("UPDATE leads") and "SET tenant_id = $1" in normalized:
for lead in self.leads.values():
if not lead.get("tenant_id"):
lead["tenant_id"] = args[0]
return "UPDATE"
if normalized.startswith("UPDATE chat_logs") and "SET tenant_id = $1" in normalized:
for log in self.chat_logs.values():
if not log.get("tenant_id"):
log["tenant_id"] = args[0]
return "UPDATE"
if normalized.startswith("ALTER TABLE leads ALTER COLUMN tenant_id SET DEFAULT"):
return "ALTER TABLE"
if normalized.startswith("ALTER TABLE chat_logs ALTER COLUMN tenant_id SET DEFAULT"):
return "ALTER TABLE"
if "CREATE INDEX IF NOT EXISTS" in normalized:
return "CREATE INDEX"
if normalized.startswith("DELETE FROM leads WHERE id = $1 AND tenant_id = $2"):
existed = self.leads.get(args[0])
if existed and existed["tenant_id"] == args[1]:
self.leads.pop(args[0], None)
return "DELETE 1"
return "DELETE 0"
if normalized.startswith("DELETE FROM leads WHERE id = $1"):
existed = self.leads.pop(args[0], None)
return "DELETE 1" if existed else "DELETE 0"
@@ -33,18 +65,22 @@ class FakeConn:
async def fetchrow(self, query: str, *args):
normalized = query.strip()
if "INSERT INTO leads" in normalized:
has_tenant = "tenant_id" in normalized.split("(", 1)[1].split(")", 1)[0]
tenant_id = args[1] if has_tenant else "tenant_velocity"
base = 2 if has_tenant else 1
row = {
"id": args[0],
"name": args[1],
"email": args[2],
"phone": args[3],
"source": args[4],
"notes": args[5],
"qualification": args[6],
"score": args[7],
"kanban_status": args[8],
"budget": args[9],
"unit_interest": args[10],
"tenant_id": tenant_id,
"name": args[base],
"email": args[base + 1],
"phone": args[base + 2],
"source": args[base + 3],
"notes": args[base + 4],
"qualification": args[base + 5],
"score": args[base + 6],
"kanban_status": args[base + 7],
"budget": args[base + 8],
"unit_interest": args[base + 9],
"metadata": {},
"created_at": _now(),
"updated_at": _now(),
@@ -53,7 +89,7 @@ class FakeConn:
return row
if normalized.startswith("UPDATE leads") and "SET kanban_status" in normalized:
lead = self.leads.get(args[0])
if not lead:
if not lead or lead["tenant_id"] != args[2]:
return None
lead["kanban_status"] = args[1]
lead["updated_at"] = _now()
@@ -66,7 +102,7 @@ class FakeConn:
return lead
if normalized.startswith("UPDATE leads") and "RETURNING" in normalized:
lead = self.leads.get(args[0])
if not lead:
if not lead or lead["tenant_id"] != args[12]:
return None
lead.update(
{
@@ -84,38 +120,47 @@ class FakeConn:
}
)
return lead
if normalized.startswith("SELECT id FROM leads WHERE id = $1"):
if normalized.startswith("SELECT id FROM leads WHERE id = $1 AND tenant_id = $2"):
lead = self.leads.get(args[0])
return {"id": lead["id"]} if lead else None
return {"id": lead["id"]} if lead and lead["tenant_id"] == args[1] else None
if "INSERT INTO chat_logs" in normalized:
has_tenant = "tenant_id" in normalized.split("(", 1)[1].split(")", 1)[0]
tenant_id = args[1] if has_tenant else "tenant_velocity"
base = 2 if has_tenant else 1
row = {
"id": args[0],
"lead_id": args[1],
"sender": args[2],
"channel": args[3],
"content": args[4],
"tenant_id": tenant_id,
"lead_id": args[base],
"sender": args[base + 1],
"channel": args[base + 2],
"content": args[base + 3],
"metadata": {},
"created_at": _now(),
}
self.chat_logs[row["id"]] = row
return row
if "FROM leads" in normalized and "WHERE id = $1 AND tenant_id = $2" in normalized:
lead = self.leads.get(args[0])
return lead if lead and lead["tenant_id"] == args[1] else None
raise AssertionError(f"Unexpected fetchrow query: {query}")
async def fetch(self, query: str, *args):
normalized = query.strip()
if "FROM leads" in normalized and "GROUP BY source" not in normalized and "GROUP BY qualification" not in normalized:
rows = list(self.leads.values())
if "WHERE kanban_status = $1" in normalized:
rows = [row for row in rows if row["kanban_status"] == args[0]]
rows = [row for row in self.leads.values() if row["tenant_id"] == args[0]]
if "WHERE tenant_id = $1 AND kanban_status = $2" in normalized:
rows = [row for row in rows if row["kanban_status"] == args[1]]
return rows
if "FROM chat_logs" in normalized:
rows = list(self.chat_logs.values())
if "WHERE lead_id = $1" in normalized:
rows = [row for row in rows if row["lead_id"] == args[0]]
rows = [row for row in self.chat_logs.values() if row["tenant_id"] == args[0]]
if "WHERE tenant_id = $1 AND lead_id = $2" in normalized:
rows = [row for row in rows if row["lead_id"] == args[1]]
return rows
if "GROUP BY source" in normalized:
grouped: dict[str, dict[str, Any]] = {}
for lead in self.leads.values():
if lead["tenant_id"] != args[0]:
continue
slot = grouped.setdefault(lead["source"], {"source": lead["source"], "lead_count": 0, "avg_score": 0.0})
slot["lead_count"] += 1
slot["avg_score"] += float(lead["score"])
@@ -125,6 +170,8 @@ class FakeConn:
if "GROUP BY qualification" in normalized:
grouped: dict[str, dict[str, Any]] = {}
for lead in self.leads.values():
if lead["tenant_id"] != args[0]:
continue
slot = grouped.setdefault(lead["qualification"], {"qualification": lead["qualification"], "lead_count": 0})
slot["lead_count"] += 1
return list(grouped.values())
@@ -140,15 +187,34 @@ class FakePool:
yield self.conn
def _build_client() -> tuple[TestClient, FakePool]:
def _build_client(
*,
authenticated: bool = True,
tenant_id: str = "tenant_velocity",
) -> tuple[TestClient, FakePool]:
app = FastAPI()
pool = FakePool()
app.state.db_pool = pool
app.include_router(crm_router, prefix="/api")
app.include_router(analytics_router, prefix="/api/analytics")
if authenticated:
app.dependency_overrides[get_current_user] = lambda: UserPrincipal("user-1", "ADMIN", tenant_id)
return TestClient(app), pool
def _build_shared_app(pool: FakePool, current_user: dict[str, str]) -> TestClient:
app = FastAPI()
app.state.db_pool = pool
app.include_router(crm_router, prefix="/api")
app.include_router(analytics_router, prefix="/api/analytics")
app.dependency_overrides[get_current_user] = lambda: UserPrincipal(
"user-1",
current_user["role"],
current_user["tenant_id"],
)
return TestClient(app)
def test_crm_crud_and_analytics_flow() -> None:
client, _pool = _build_client()
@@ -213,3 +279,42 @@ def test_lead_demographics_groups_by_source_and_qualification() -> None:
payload = response.json()["data"]
assert len(payload["by_source"]) == 2
assert any(row["qualification"] == "POTENTIAL" for row in payload["by_qualification"])
def test_crm_routes_require_authentication() -> None:
client, _pool = _build_client(authenticated=False)
response = client.get("/api/leads")
assert response.status_code == 401
assert response.json()["detail"] == "Missing or malformed Authorization header."
def test_crm_routes_are_scoped_to_authenticated_tenant() -> None:
pool = FakePool()
tenant_a = {"role": "ADMIN", "tenant_id": "tenant_alpha"}
client_a = _build_shared_app(pool, tenant_a)
create_response = client_a.post(
"/api/leads",
json={"name": "Tenant Alpha Lead", "source": "website", "score": 88},
)
assert create_response.status_code == 201
lead_id = create_response.json()["data"]["id"]
tenant_b = {"role": "ADMIN", "tenant_id": "tenant_beta"}
client_b = _build_shared_app(pool, tenant_b)
list_response = client_b.get("/api/leads")
assert list_response.status_code == 200
assert list_response.json()["meta"]["count"] == 0
get_response = client_b.get(f"/api/leads/{lead_id}")
assert get_response.status_code == 404
delete_response = client_b.delete(f"/api/leads/{lead_id}")
assert delete_response.status_code == 404
own_list_response = client_a.get("/api/leads")
assert own_list_response.status_code == 200
assert own_list_response.json()["meta"]["count"] == 1

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
from comfy_engine.scripts.gateway_auth import (
extract_gateway_api_key,
is_gateway_request_authorized,
load_gateway_api_key,
)
def test_load_gateway_api_key_prefers_explicit_gateway_env() -> None:
env = {
"DREAM_WEAVER_API_KEY": "fallback-key",
"DREAM_WEAVER_GATEWAY_API_KEY": "primary-key",
}
assert load_gateway_api_key(env) == "primary-key"
def test_extract_gateway_api_key_supports_dedicated_headers() -> None:
assert extract_gateway_api_key({"x-dream-weaver-api-key": "dw-key"}) == "dw-key"
assert extract_gateway_api_key({"x-api-key": "legacy-key"}) == "legacy-key"
def test_extract_gateway_api_key_supports_bearer_authorization() -> None:
assert extract_gateway_api_key({"authorization": "Bearer shared-key"}) == "shared-key"
def test_gateway_auth_allows_open_gateways_and_blocks_wrong_keys() -> None:
assert is_gateway_request_authorized({}, None) is True
assert is_gateway_request_authorized({"x-dream-weaver-api-key": "correct"}, "correct") is True
assert is_gateway_request_authorized({"authorization": "Bearer wrong"}, "correct") is False

View File

@@ -0,0 +1,238 @@
from __future__ import annotations
import os
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from typing import Any
from fastapi import FastAPI
from fastapi.testclient import TestClient
os.environ.setdefault("VELOCITY_JWT_SECRET", "test-secret")
from backend.api import routes_crm
from backend.auth.dependencies import UserPrincipal, get_current_user
def _now() -> datetime:
return datetime.now(timezone.utc)
class FakeConn:
def __init__(self) -> None:
self.leads: dict[str, dict[str, Any]] = {
"legacy-1": {
"id": "legacy-1",
"tenant_id": "tenant_alpha",
"name": "Legacy Duplicate",
"email": "legacy.duplicate@example.com",
"phone": "+971500000001",
"source": "website",
"notes": "Old legacy note",
"qualification": "HOT",
"score": 82,
"kanban_status": "Qualifying",
"budget": "AED 5M",
"unit_interest": "Legacy Tower",
"metadata": {},
"created_at": _now(),
"updated_at": _now(),
},
"legacy-2": {
"id": "legacy-2",
"tenant_id": "tenant_alpha",
"name": "Legacy Only",
"email": "legacy.only@example.com",
"phone": "+971500000002",
"source": "walkin",
"notes": "Pure legacy lead",
"qualification": "POTENTIAL",
"score": 74,
"kanban_status": "Negotiation",
"budget": "AED 7M",
"unit_interest": "Legacy Residence",
"metadata": {},
"created_at": _now(),
"updated_at": _now(),
},
}
self.chat_logs: dict[str, dict[str, Any]] = {}
async def execute(self, query: str, *args):
normalized = " ".join(query.strip().split())
if "CREATE TABLE IF NOT EXISTS leads" in normalized or "CREATE TABLE IF NOT EXISTS chat_logs" in normalized:
return "CREATE"
if normalized.startswith("ALTER TABLE leads ADD COLUMN IF NOT EXISTS tenant_id"):
return "ALTER TABLE"
if normalized.startswith("ALTER TABLE chat_logs ADD COLUMN IF NOT EXISTS tenant_id"):
return "ALTER TABLE"
if normalized.startswith("UPDATE leads SET tenant_id = $1 WHERE tenant_id IS NULL OR tenant_id = ''"):
return "UPDATE"
if normalized.startswith("UPDATE chat_logs SET tenant_id = $1 WHERE tenant_id IS NULL OR tenant_id = ''"):
return "UPDATE"
if normalized.startswith("ALTER TABLE leads ALTER COLUMN tenant_id SET DEFAULT"):
return "ALTER TABLE"
if normalized.startswith("ALTER TABLE chat_logs ALTER COLUMN tenant_id SET DEFAULT"):
return "ALTER TABLE"
if "CREATE INDEX IF NOT EXISTS" in normalized:
return "CREATE INDEX"
raise AssertionError(f"Unexpected execute query: {query}")
async def fetch(self, query: str, *args):
normalized = " ".join(query.strip().split())
if "FROM leads" in normalized:
rows = [row for row in self.leads.values() if row["tenant_id"] == args[0]]
return rows
if "FROM chat_logs" in normalized:
rows = [row for row in self.chat_logs.values() if row["tenant_id"] == args[0]]
if len(args) >= 2:
rows = [row for row in rows if row["lead_id"] == args[1]]
return rows
raise AssertionError(f"Unexpected fetch query: {query}")
async def fetchrow(self, query: str, *args):
normalized = " ".join(query.strip().split())
if "FROM leads" in normalized and "WHERE id = $1 AND tenant_id = $2" in normalized:
row = self.leads.get(args[0])
return row if row and row["tenant_id"] == args[1] else None
raise AssertionError(f"Unexpected fetchrow query: {query}")
class FakePool:
def __init__(self) -> None:
self.conn = FakeConn()
@asynccontextmanager
async def acquire(self):
yield self.conn
def _build_client() -> TestClient:
app = FastAPI()
app.state.db_pool = FakePool()
app.include_router(routes_crm.crm_router, prefix="/api")
app.dependency_overrides[get_current_user] = lambda: UserPrincipal(
"user-1",
"ADMIN",
"tenant_alpha",
)
return TestClient(app)
def test_list_leads_merges_canonical_and_legacy_without_duplicate_shadow(monkeypatch) -> None:
client = _build_client()
async def fake_fetch_canonical_leads(conn: Any, tenant_id: str, search: str | None = None) -> list[dict[str, Any]]:
assert tenant_id == "tenant_alpha"
return [
{
"id": "legacy-1",
"name": "Canonical Preferred",
"email": "canonical@example.com",
"phone": "+971500009999",
"source": "website",
"notes": "Canonical note",
"qualification": "WHALE",
"score": 96,
"kanban_status": "Negotiation",
"stage": "negotiation",
"budget": "AED 18M",
"unit_interest": "Sky Deck Penthouse",
"metadata": {
"legacy_lead_id": "legacy-1",
"canonical_lead_id": "canon-1",
"canonical_person_id": "person-1",
},
"created_at": "2026-04-22T10:00:00+00:00",
"updated_at": "2026-04-22T11:00:00+00:00",
}
]
monkeypatch.setattr(routes_crm, "_fetch_canonical_leads", fake_fetch_canonical_leads)
response = client.get("/api/leads")
assert response.status_code == 200
payload = response.json()["data"]
assert len(payload) == 2
assert payload[0]["name"] == "Canonical Preferred"
assert payload[1]["id"] == "legacy-2"
def test_get_lead_resolves_canonical_record_by_canonical_id(monkeypatch) -> None:
client = _build_client()
async def fake_fetch_canonical_leads(conn: Any, tenant_id: str, search: str | None = None) -> list[dict[str, Any]]:
return [
{
"id": "legacy-1",
"name": "Canonical Preferred",
"email": "canonical@example.com",
"phone": "+971500009999",
"source": "website",
"notes": "Canonical note",
"qualification": "WHALE",
"score": 96,
"kanban_status": "Negotiation",
"stage": "negotiation",
"budget": "AED 18M",
"unit_interest": "Sky Deck Penthouse",
"metadata": {
"legacy_lead_id": "legacy-1",
"canonical_lead_id": "canon-1",
"canonical_person_id": "person-1",
},
"created_at": "2026-04-22T10:00:00+00:00",
"updated_at": "2026-04-22T11:00:00+00:00",
}
]
monkeypatch.setattr(routes_crm, "_fetch_canonical_leads", fake_fetch_canonical_leads)
response = client.get("/api/leads/canon-1")
assert response.status_code == 200
assert response.json()["data"]["name"] == "Canonical Preferred"
def test_chat_logs_fall_back_to_canonical_interactions(monkeypatch) -> None:
client = _build_client()
async def fake_fetch_canonical_chat_logs(conn: Any, tenant_id: str, lead_id: str, channel: str | None = None) -> list[dict[str, Any]]:
assert tenant_id == "tenant_alpha"
assert lead_id == "canon-1"
return [
{
"id": "interaction-1",
"lead_id": "canon-1",
"sender": "oracle",
"channel": "whatsapp",
"content": "Canonical interaction summary",
"metadata": {"source_of_truth": "canonical_crm"},
"created_at": "2026-04-22T12:00:00+00:00",
}
]
monkeypatch.setattr(routes_crm, "_fetch_canonical_chat_logs", fake_fetch_canonical_chat_logs)
response = client.get("/api/chat-logs", params={"lead_id": "canon-1"})
assert response.status_code == 200
payload = response.json()["data"]
assert len(payload) == 1
assert payload[0]["content"] == "Canonical interaction summary"
def test_list_leads_falls_back_to_legacy_when_canonical_bridge_unavailable(monkeypatch) -> None:
client = _build_client()
async def failing_fetch_canonical_leads(conn: Any, tenant_id: str, search: str | None = None) -> list[dict[str, Any]]:
raise RuntimeError("canonical tables unavailable")
monkeypatch.setattr(routes_crm, "_fetch_canonical_leads", failing_fetch_canonical_leads)
response = client.get("/api/leads")
assert response.status_code == 200
payload = response.json()["data"]
assert [lead["id"] for lead in payload] == ["legacy-1", "legacy-2"]

View File

@@ -0,0 +1,243 @@
from __future__ import annotations
import os
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from typing import Any
from fastapi import FastAPI
from fastapi.testclient import TestClient
os.environ.setdefault("VELOCITY_JWT_SECRET", "test-secret")
from backend.api.routes_crm import crm_router
from backend.auth.dependencies import UserPrincipal, get_current_user
def _now() -> datetime:
return datetime.now(timezone.utc)
class FakeConn:
def __init__(self) -> None:
self.leads: dict[str, dict[str, Any]] = {}
self.chat_logs: dict[str, dict[str, Any]] = {}
async def execute(self, query: str, *args):
normalized = " ".join(query.strip().split())
if "CREATE TABLE IF NOT EXISTS leads" in normalized or "CREATE TABLE IF NOT EXISTS chat_logs" in normalized:
return "CREATE"
if normalized.startswith("ALTER TABLE leads ADD COLUMN IF NOT EXISTS tenant_id"):
return "ALTER TABLE"
if normalized.startswith("ALTER TABLE chat_logs ADD COLUMN IF NOT EXISTS tenant_id"):
return "ALTER TABLE"
if normalized.startswith("UPDATE leads SET tenant_id = $1 WHERE tenant_id IS NULL OR tenant_id = ''"):
return "UPDATE"
if normalized.startswith("UPDATE chat_logs SET tenant_id = $1 WHERE tenant_id IS NULL OR tenant_id = ''"):
return "UPDATE"
if normalized.startswith("ALTER TABLE leads ALTER COLUMN tenant_id SET DEFAULT"):
return "ALTER TABLE"
if normalized.startswith("ALTER TABLE chat_logs ALTER COLUMN tenant_id SET DEFAULT"):
return "ALTER TABLE"
if "CREATE INDEX IF NOT EXISTS" in normalized:
return "CREATE INDEX"
if normalized.startswith("DELETE FROM leads WHERE id = $1 AND tenant_id = $2"):
existed = self.leads.get(args[0])
if existed and existed["tenant_id"] == args[1]:
self.leads.pop(args[0], None)
return "DELETE 1"
return "DELETE 0"
raise AssertionError(f"Unexpected execute query: {query}")
async def fetchrow(self, query: str, *args):
normalized = " ".join(query.strip().split())
if "INSERT INTO leads" in normalized:
row = {
"id": args[0],
"tenant_id": args[1],
"name": args[2],
"email": args[3],
"phone": args[4],
"source": args[5],
"notes": args[6],
"qualification": args[7],
"score": args[8],
"kanban_status": args[9],
"budget": args[10],
"unit_interest": args[11],
"metadata": {},
"created_at": _now(),
"updated_at": _now(),
}
self.leads[row["id"]] = row
return row
if normalized.startswith("UPDATE leads") and "RETURNING" in normalized:
row = self.leads.get(args[0])
tenant_arg = args[-1]
if not row or row["tenant_id"] != tenant_arg:
return None
if len(args) == 13:
row.update(
{
"name": args[1],
"email": args[2],
"phone": args[3],
"source": args[4],
"notes": args[5],
"qualification": args[6],
"score": args[7],
"kanban_status": args[8],
"budget": args[9],
"unit_interest": args[10],
"updated_at": _now(),
}
)
else:
row.update(
{
"kanban_status": args[1],
"qualification": "HOT" if row["score"] >= 45 else row["qualification"],
"updated_at": _now(),
}
)
return row
if normalized.startswith("SELECT id, name, email, phone, source, notes, qualification, score, kanban_status, budget, unit_interest, metadata, created_at, updated_at FROM leads WHERE id = $1 AND tenant_id = $2"):
row = self.leads.get(args[0])
return row if row and row["tenant_id"] == args[1] else None
if normalized.startswith("INSERT INTO chat_logs"):
row = {
"id": args[0],
"tenant_id": args[1],
"lead_id": args[2],
"sender": args[3],
"channel": args[4],
"content": args[5],
"metadata": {},
"created_at": _now(),
}
self.chat_logs[row["id"]] = row
return row
raise AssertionError(f"Unexpected fetchrow query: {query}")
class FakePool:
def __init__(self) -> None:
self.conn = FakeConn()
@asynccontextmanager
async def acquire(self):
yield self.conn
def _build_client() -> tuple[TestClient, FakePool]:
app = FastAPI()
pool = FakePool()
app.state.db_pool = pool
app.include_router(crm_router, prefix="/api")
app.dependency_overrides[get_current_user] = lambda: UserPrincipal("user-1", "ADMIN", "tenant_alpha")
return TestClient(app), pool
def test_create_lead_triggers_canonical_write_bridge(monkeypatch) -> None:
client, _pool = _build_client()
calls: list[dict[str, Any]] = []
async def fake_sync(request, conn, user, legacy_lead):
calls.append({"lead_id": legacy_lead["id"], "name": legacy_lead["name"], "tenant_id": user.tenant_id})
return {"person_id": "person-1", "lead_id": "canon-1"}
monkeypatch.setattr("backend.api.routes_crm._sync_canonical_lead_bridge", fake_sync)
response = client.post("/api/leads", json={"name": "Amina Rahman", "source": "website", "score": 88})
assert response.status_code == 201
assert len(calls) == 1
assert calls[0]["name"] == "Amina Rahman"
assert calls[0]["tenant_id"] == "tenant_alpha"
def test_update_lead_triggers_canonical_write_bridge(monkeypatch) -> None:
client, _pool = _build_client()
calls: list[dict[str, Any]] = []
async def fake_sync(request, conn, user, legacy_lead):
calls.append({"lead_id": legacy_lead["id"], "status": legacy_lead["kanban_status"]})
return {"person_id": "person-1", "lead_id": "canon-1"}
monkeypatch.setattr("backend.api.routes_crm._sync_canonical_lead_bridge", fake_sync)
create_response = client.post("/api/leads", json={"name": "Lead One", "source": "website", "score": 60})
lead_id = create_response.json()["data"]["id"]
calls.clear()
update_response = client.put(
f"/api/leads/{lead_id}",
json={"name": "Lead One Updated", "source": "website", "score": 75, "kanban_status": "negotiation"},
)
assert update_response.status_code == 200
assert len(calls) == 1
assert calls[0]["lead_id"] == lead_id
assert calls[0]["status"] == "Negotiation"
def test_create_chat_log_triggers_canonical_chat_bridge(monkeypatch) -> None:
client, _pool = _build_client()
calls: list[dict[str, Any]] = []
async def fake_lead_sync(request, conn, user, legacy_lead):
return {"person_id": "person-1", "lead_id": "canon-1"}
async def fake_chat_sync(request, conn, user, legacy_chat_log, legacy_lead):
calls.append(
{
"chat_log_id": legacy_chat_log["id"],
"lead_id": legacy_chat_log["lead_id"],
"content": legacy_chat_log["content"],
"lead_name": legacy_lead["name"],
}
)
monkeypatch.setattr("backend.api.routes_crm._sync_canonical_lead_bridge", fake_lead_sync)
monkeypatch.setattr("backend.api.routes_crm._sync_canonical_chat_log_bridge", fake_chat_sync)
create_response = client.post("/api/leads", json={"name": "Lead One", "source": "website", "score": 60})
lead_id = create_response.json()["data"]["id"]
response = client.post(
"/api/chat-logs",
json={"lead_id": lead_id, "sender": "oracle", "channel": "whatsapp", "content": "Follow up tonight"},
)
assert response.status_code == 201
assert len(calls) == 1
assert calls[0]["lead_id"] == lead_id
assert calls[0]["content"] == "Follow up tonight"
def test_move_and_delete_trigger_canonical_write_bridges(monkeypatch) -> None:
client, _pool = _build_client()
move_calls: list[dict[str, Any]] = []
delete_calls: list[str] = []
async def fake_sync(request, conn, user, legacy_lead):
move_calls.append({"lead_id": legacy_lead["id"], "status": legacy_lead["kanban_status"]})
return {"person_id": "person-1", "lead_id": "canon-1"}
async def fake_delete(request, conn, user, legacy_lead_id):
delete_calls.append(legacy_lead_id)
monkeypatch.setattr("backend.api.routes_crm._sync_canonical_lead_bridge", fake_sync)
monkeypatch.setattr("backend.api.routes_crm._delete_canonical_lead_bridge", fake_delete)
create_response = client.post("/api/leads", json={"name": "Lead One", "source": "website", "score": 60})
lead_id = create_response.json()["data"]["id"]
move_calls.clear()
move_response = client.put("/api/kanban/move", json={"lead_id": lead_id, "target_status": "site_visit"})
delete_response = client.delete(f"/api/leads/{lead_id}")
assert move_response.status_code == 200
assert delete_response.status_code == 200
assert move_calls == [{"lead_id": lead_id, "status": "Site Visit"}]
assert delete_calls == [lead_id]

View File

@@ -0,0 +1,40 @@
from __future__ import annotations
import os
from pathlib import Path
from fastapi import FastAPI
from fastapi.testclient import TestClient
os.environ.setdefault("VELOCITY_JWT_SECRET", "test-secret")
from backend.migrations.runner import discover_migrations
from backend.observability import RequestObservabilityMiddleware
def test_migration_discovery_is_ordered_and_checksummed() -> None:
migrations = discover_migrations(Path("backend/migrations/versions"))
assert migrations
assert migrations == sorted(migrations, key=lambda migration: migration.version)
assert all(len(migration.checksum) == 64 for migration in migrations)
assert len({migration.version for migration in migrations}) == len(migrations)
def test_observability_middleware_adds_request_headers_and_snapshot() -> None:
app = FastAPI()
app.add_middleware(RequestObservabilityMiddleware)
@app.get("/ping")
async def ping() -> dict[str, str]:
return {"status": "ok"}
client = TestClient(app)
response = client.get("/ping", headers={"X-Request-ID": "req-test"})
assert response.status_code == 200
assert response.headers["X-Request-ID"] == "req-test"
assert "X-Response-Time-Ms" in response.headers
assert app.state.request_metrics[-1].request_id == "req-test"
assert app.state.request_metrics[-1].path == "/ping"

View File

@@ -0,0 +1,470 @@
from __future__ import annotations
import os
import uuid
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from typing import Any
from fastapi import FastAPI
from fastapi.testclient import TestClient
os.environ.setdefault("VELOCITY_JWT_SECRET", "test-secret")
from backend.api.routes_inventory import router as inventory_router
from backend.api.routes_mobile_edge import router as mobile_edge_router
from backend.auth.dependencies import UserPrincipal, get_current_user
def _now() -> datetime:
return datetime.now(timezone.utc)
class FakeSurfaceConn:
def __init__(self) -> None:
self.events: dict[str, dict[str, Any]] = {}
self.calendar_events: dict[str, dict[str, Any]] = {}
self.properties: dict[str, dict[str, Any]] = {}
self.import_batches: dict[str, dict[str, Any]] = {}
async def fetchrow(self, query: str, *args):
normalized = " ".join(query.strip().split())
if "INSERT INTO edge_communication_events" in normalized:
event_id = str(uuid.uuid4())
row = {
"event_id": event_id,
"tenant_id": args[0],
"lead_id": args[1],
"channel": args[2],
"direction": args[3],
"provider": args[4],
"capture_mode": args[5],
"consent_state": args[6],
"duration_seconds": args[7],
"summary": args[8],
"raw_reference": args[9],
"recording_ref": args[10],
"provider_metadata": args[11],
"timestamp": _now(),
"created_at": _now(),
}
self.events[event_id] = row
return {"event_id": event_id, "created_at": row["created_at"]}
if "INSERT INTO user_calendar_events" in normalized:
calendar_event_id = str(uuid.uuid4())
row = {
"calendar_event_id": calendar_event_id,
"tenant_id": args[0],
"owner_user_id": args[1],
"lead_id": args[2],
"source_event_id": args[3],
"title": args[4],
"description": args[5],
"start_at": args[6],
"end_at": args[7],
"all_day": args[8],
"status": args[9],
"reminder_minutes": args[10],
"created_by": args[11],
"location": args[12],
"metadata": args[13],
"created_at": _now(),
}
self.calendar_events[calendar_event_id] = row
return row
if "INSERT INTO inventory_import_batches" in normalized:
batch_id = str(uuid.uuid4())
row = {
"batch_id": batch_id,
"tenant_id": args[0],
"source_type": args[1],
"submitted_by": args[2],
"total_rows": args[3],
"source_file_ref": args[4],
"accepted_rows": 0,
"rejected_rows": 0,
"status": "pending",
"created_at": _now(),
"completed_at": None,
}
self.import_batches[batch_id] = row
return {
"batch_id": batch_id,
"status": row["status"],
"created_at": row["created_at"],
}
if "INSERT INTO inventory_properties" in normalized:
property_id = str(uuid.uuid4())
row = {
"property_id": property_id,
"tenant_id": args[0],
"batch_id": args[1],
"source_id": args[2],
"project_name": args[3],
"developer_name": args[4],
"location": args[5],
"property_type": args[6],
"price_bands": args[7],
"unit_mix": args[8],
"amenities": args[9],
"status": args[10],
"validation_state": args[11],
"ingested_at": None,
"created_at": _now(),
"updated_at": _now(),
}
self.properties[property_id] = row
return {"property_id": property_id, "created_at": row["created_at"]}
if normalized.startswith("SELECT * FROM inventory_import_batches WHERE batch_id=$1 AND tenant_id=$2"):
row = self.import_batches.get(args[0])
return row if row and row["tenant_id"] == args[1] else None
if normalized.startswith("SELECT * FROM inventory_properties WHERE property_id=$1 AND tenant_id=$2"):
row = self.properties.get(args[0])
return row if row and row["tenant_id"] == args[1] else None
raise AssertionError(f"Unexpected fetchrow query: {query}")
async def fetch(self, query: str, *args):
normalized = " ".join(query.strip().split())
if "FROM edge_communication_events" in normalized:
tenant_id, lead_id, limit, offset = args
rows = [
{
"event_id": row["event_id"],
"lead_id": row["lead_id"],
"channel": row["channel"],
"direction": row["direction"],
"provider": row["provider"],
"capture_mode": row["capture_mode"],
"consent_state": row["consent_state"],
"timestamp": row["timestamp"].isoformat(),
"duration_seconds": row["duration_seconds"],
"summary": row["summary"],
"raw_reference": row["raw_reference"],
"recording_ref": row["recording_ref"],
"provider_metadata": row["provider_metadata"],
"created_at": row["created_at"].isoformat(),
}
for row in self.events.values()
if row["tenant_id"] == tenant_id and row["lead_id"] == lead_id
]
rows.sort(key=lambda item: item["timestamp"], reverse=True)
return rows[offset : offset + limit]
if "FROM user_calendar_events" in normalized:
tenant_id = args[0]
owner_user_id = args[1]
limit = args[-1]
rows = [
{
"calendar_event_id": row["calendar_event_id"],
"lead_id": row["lead_id"],
"title": row["title"],
"description": row["description"],
"start_at": row["start_at"],
"end_at": row["end_at"],
"all_day": row["all_day"],
"status": row["status"],
"reminder_minutes": row["reminder_minutes"],
"created_by": row["created_by"],
"location": row["location"],
"metadata": row["metadata"],
"created_at": row["created_at"].isoformat(),
}
for row in self.calendar_events.values()
if row["tenant_id"] == tenant_id and row["owner_user_id"] == owner_user_id
and row["status"] != "cancelled"
]
rows.sort(key=lambda item: item["start_at"])
return rows[:limit]
if "FROM inventory_import_batches" in normalized:
tenant_id, limit, offset = args
rows = [
{
"batch_id": row["batch_id"],
"source_type": row["source_type"],
"submitted_by": row["submitted_by"],
"status": row["status"],
"total_rows": row["total_rows"],
"accepted_rows": row["accepted_rows"],
"rejected_rows": row["rejected_rows"],
"created_at": row["created_at"].isoformat(),
"completed_at": row["completed_at"],
}
for row in self.import_batches.values()
if row["tenant_id"] == tenant_id
]
rows.sort(key=lambda item: item["created_at"], reverse=True)
return rows[offset : offset + limit]
if "FROM inventory_properties" in normalized:
params = list(args)
tenant_id = params[0]
limit = params[-2]
offset = params[-1]
status_filter = None
property_type = None
if len(params) == 4:
status_filter = params[1]
if len(params) == 5:
status_filter = params[1]
property_type = params[2]
rows = [
{
"property_id": row["property_id"],
"project_name": row["project_name"],
"developer_name": row["developer_name"],
"property_type": row["property_type"],
"location": row["location"],
"price_bands": row["price_bands"],
"unit_mix": row["unit_mix"],
"status": row["status"],
"ingested_at": row["ingested_at"],
"created_at": row["created_at"].isoformat(),
}
for row in self.properties.values()
if row["tenant_id"] == tenant_id
and (status_filter is None or row["status"] == status_filter)
and (property_type is None or row["property_type"] == property_type)
]
rows.sort(key=lambda item: item["created_at"], reverse=True)
return rows[offset : offset + limit]
raise AssertionError(f"Unexpected fetch query: {query}")
async def fetchval(self, query: str, *args):
normalized = " ".join(query.strip().split())
if normalized.startswith("SELECT COUNT(*) FROM edge_communication_events WHERE tenant_id = $1 AND lead_id = $2"):
tenant_id, lead_id = args
return sum(
1
for row in self.events.values()
if row["tenant_id"] == tenant_id and row["lead_id"] == lead_id
)
if normalized.startswith("SELECT COUNT(*) FROM inventory_import_batches WHERE tenant_id=$1"):
tenant_id = args[0]
return sum(1 for row in self.import_batches.values() if row["tenant_id"] == tenant_id)
if normalized.startswith("SELECT COUNT(*) FROM inventory_properties WHERE tenant_id = $1"):
tenant_id = args[0]
return sum(1 for row in self.properties.values() if row["tenant_id"] == tenant_id)
raise AssertionError(f"Unexpected fetchval query: {query}")
async def execute(self, query: str, *args):
normalized = " ".join(query.strip().split())
if "UPDATE user_calendar_events SET status='cancelled'" in normalized:
tenant_id, owner_user_id, calendar_event_id = args
row = self.calendar_events.get(calendar_event_id)
if not row or row["tenant_id"] != tenant_id or row["owner_user_id"] != owner_user_id:
return "UPDATE 0"
row["status"] = "cancelled"
return "UPDATE 1"
if normalized.startswith("UPDATE user_calendar_events SET"):
tenant_id = args[-3]
owner_user_id = args[-2]
calendar_event_id = args[-1]
row = self.calendar_events.get(calendar_event_id)
if not row or row["tenant_id"] != tenant_id or row["owner_user_id"] != owner_user_id:
return "UPDATE 0"
assignments = normalized.split(" SET ", 1)[1].split(" WHERE ", 1)[0].split(", ")
for assignment, value in zip(assignments, args):
column = assignment.split(" = ", 1)[0]
if column not in {"tenant_id", "owner_user_id"}:
row[column] = value
return "UPDATE 1"
raise AssertionError(f"Unexpected execute query: {query}")
class FakeSurfacePool:
def __init__(self) -> None:
self.conn = FakeSurfaceConn()
@asynccontextmanager
async def acquire(self):
yield self.conn
def _build_shared_app(pool: FakeSurfacePool, current_user: dict[str, str]) -> TestClient:
app = FastAPI()
app.state.db_pool = pool
app.include_router(mobile_edge_router, prefix="/api/mobile-edge")
app.include_router(inventory_router, prefix="/api/inventory")
app.dependency_overrides[get_current_user] = lambda: UserPrincipal(
current_user["user_id"],
current_user["role"],
current_user["tenant_id"],
)
return TestClient(app)
def test_mobile_edge_event_routes_scope_by_tenant_id_instead_of_role() -> None:
pool = FakeSurfacePool()
tenant_a = _build_shared_app(
pool,
{"user_id": "user-a", "role": "ADMIN", "tenant_id": "tenant_alpha"},
)
tenant_b = _build_shared_app(
pool,
{"user_id": "user-b", "role": "ADMIN", "tenant_id": "tenant_beta"},
)
create_response = tenant_a.post(
"/api/mobile-edge/events",
json={
"lead_id": "lead-123",
"channel": "whatsapp_message",
"direction": "inbound",
"capture_mode": "operator_note",
"consent_state": "granted",
"summary": "Client asked for a marina brochure.",
},
)
assert create_response.status_code == 201
tenant_a_events = tenant_a.get("/api/mobile-edge/events", params={"lead_id": "lead-123", "limit": 10})
assert tenant_a_events.status_code == 200
assert tenant_a_events.json()["total"] == 1
tenant_b_events = tenant_b.get("/api/mobile-edge/events", params={"lead_id": "lead-123", "limit": 10})
assert tenant_b_events.status_code == 200
assert tenant_b_events.json()["total"] == 0
assert tenant_b_events.json()["events"] == []
def test_mobile_edge_calendar_routes_scope_by_tenant_id_instead_of_role() -> None:
pool = FakeSurfacePool()
tenant_a = _build_shared_app(
pool,
{"user_id": "user-a", "role": "ADMIN", "tenant_id": "tenant_alpha"},
)
tenant_b = _build_shared_app(
pool,
{"user_id": "user-b", "role": "ADMIN", "tenant_id": "tenant_beta"},
)
create_response = tenant_a.post(
"/api/mobile-edge/calendar",
json={
"lead_id": "lead-123",
"title": "Private site visit",
"description": "Walkthrough with the lead",
"start_at": "2026-04-23T10:00:00Z",
"end_at": "2026-04-23T11:00:00Z",
"all_day": False,
"status": "tentative",
"reminder_minutes": [15],
"location": "Dubai Marina",
"metadata": {"source": "ipad"},
},
)
assert create_response.status_code == 201
created_payload = create_response.json()
assert created_payload["status"] == "ok"
assert created_payload["event"]["title"] == "Private site visit"
assert created_payload["event"]["location"] == "Dubai Marina"
assert created_payload["event"]["status"] == "tentative"
assert created_payload["event"]["reminder_minutes"] == [15]
tenant_a_calendar = tenant_a.get("/api/mobile-edge/calendar")
assert tenant_a_calendar.status_code == 200
assert len(tenant_a_calendar.json()["events"]) == 1
event_id = created_payload["event"]["calendar_event_id"]
update_response = tenant_a.patch(
f"/api/mobile-edge/calendar/{event_id}",
json={"status": "confirmed"},
)
assert update_response.status_code == 200
tenant_a_calendar = tenant_a.get("/api/mobile-edge/calendar")
assert tenant_a_calendar.json()["events"][0]["status"] == "confirmed"
cancel_response = tenant_a.delete(f"/api/mobile-edge/calendar/{event_id}")
assert cancel_response.status_code == 200
tenant_a_calendar = tenant_a.get("/api/mobile-edge/calendar")
assert tenant_a_calendar.json()["events"] == []
tenant_b_calendar = tenant_b.get("/api/mobile-edge/calendar")
assert tenant_b_calendar.status_code == 200
assert tenant_b_calendar.json()["events"] == []
def test_inventory_property_routes_scope_by_tenant_id_instead_of_role() -> None:
pool = FakeSurfacePool()
tenant_a = _build_shared_app(
pool,
{"user_id": "user-a", "role": "ADMIN", "tenant_id": "tenant_alpha"},
)
tenant_b = _build_shared_app(
pool,
{"user_id": "user-b", "role": "ADMIN", "tenant_id": "tenant_beta"},
)
create_response = tenant_a.post(
"/api/inventory/properties",
json={
"project_name": "Marina One",
"developer_name": "Desi Neuron Estates",
"location": {"city": "Dubai", "district": "Marina"},
"property_type": "apartment",
"price_bands": [{"label": "from", "amount": 2400000}],
"unit_mix": [{"type": "2BR", "count": 18}],
"amenities": ["pool", "gym"],
"status": "active",
"validation_state": {"validated": True},
},
)
assert create_response.status_code == 201
tenant_a_properties = tenant_a.get("/api/inventory/properties", params={"limit": 20})
assert tenant_a_properties.status_code == 200
assert tenant_a_properties.json()["total"] == 1
tenant_b_properties = tenant_b.get("/api/inventory/properties", params={"limit": 20})
assert tenant_b_properties.status_code == 200
assert tenant_b_properties.json()["total"] == 0
assert tenant_b_properties.json()["properties"] == []
def test_inventory_import_batch_routes_scope_by_tenant_id_instead_of_role() -> None:
pool = FakeSurfacePool()
tenant_a = _build_shared_app(
pool,
{"user_id": "user-a", "role": "ADMIN", "tenant_id": "tenant_alpha"},
)
tenant_b = _build_shared_app(
pool,
{"user_id": "user-b", "role": "ADMIN", "tenant_id": "tenant_beta"},
)
create_response = tenant_a.post(
"/api/inventory/import-batches",
json={
"source_type": "csv",
"source_file_ref": "s3://velocity/imports/marina.csv",
"total_rows": 24,
},
)
assert create_response.status_code == 201
tenant_a_batches = tenant_a.get("/api/inventory/import-batches", params={"limit": 20})
assert tenant_a_batches.status_code == 200
assert tenant_a_batches.json()["total"] == 1
tenant_b_batches = tenant_b.get("/api/inventory/import-batches", params={"limit": 20})
assert tenant_b_batches.status_code == 200
assert tenant_b_batches.json()["total"] == 0
assert tenant_b_batches.json()["batches"] == []

View File

@@ -21,10 +21,11 @@ from typing import Optional, List
import httpx
import uvicorn
from fastapi import FastAPI, UploadFile, File, HTTPException, Form, BackgroundTasks
from fastapi import FastAPI, UploadFile, File, HTTPException, Form, Request
from fastapi.responses import JSONResponse, StreamingResponse
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from gateway_auth import load_gateway_api_key, is_gateway_request_authorized
# Add scripts dir to path so we can import prompt_expander
SCRIPTS_DIR = Path(__file__).parent / "scripts"
@@ -40,8 +41,46 @@ except ImportError:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger("DreamWeaverGateway")
COMFY = "http://127.0.0.1:8118"
COMFY = (os.environ.get("COMFYUI_URL") or os.environ.get("COMFY_URL") or "http://127.0.0.1:8118").rstrip("/")
COMFY_TLS_VERIFY = os.environ.get("COMFYUI_TLS_VERIFY", "true").strip().lower() not in {"0", "false", "no", "off"}
COMFY_ROOT = "/opt/dlami/nvme/ComfyUI"
GATEWAY_API_KEY = load_gateway_api_key()
PREFERRED_CHECKPOINTS = [
"realvisxlV50_v50LightningBakedvae.safetensors",
"realvisxlV50Lightning_v50Lightning.safetensors",
]
def comfy_client(timeout: float = 30) -> httpx.AsyncClient:
return httpx.AsyncClient(timeout=timeout, verify=COMFY_TLS_VERIFY, follow_redirects=True)
async def list_comfy_checkpoints() -> list[str]:
async with comfy_client(timeout=10) as client:
response = await client.get(f"{COMFY}/models/checkpoints")
response.raise_for_status()
payload = response.json()
if isinstance(payload, list):
return [item for item in payload if isinstance(item, str)]
return []
async def resolve_checkpoint() -> str:
checkpoints = await list_comfy_checkpoints()
if not checkpoints:
raise HTTPException(
status_code=503,
detail=(
"ComfyUI is online but has no checkpoint models installed. "
"Hydrate RealVisXL into ComfyUI/models/checkpoints before generating."
),
)
lower_lookup = {item.lower(): item for item in checkpoints}
for preferred in PREFERRED_CHECKPOINTS:
match = lower_lookup.get(preferred.lower())
if match:
return match
return checkpoints[0]
app = FastAPI(
title="Dream Weaver API v2",
@@ -54,6 +93,12 @@ app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], all
jobs: dict = {}
def ensure_gateway_auth(request: Request) -> None:
if is_gateway_request_authorized(request.headers, GATEWAY_API_KEY):
return
raise HTTPException(status_code=401, detail="Dream Weaver gateway API key is required or invalid.")
# ─── Models ──────────────────────────────────────────────────────────────────
class ExpandRequest(BaseModel):
keywords: List[str]
@@ -74,7 +119,7 @@ class ExpandResponse(BaseModel):
# ─── ComfyUI helpers ──────────────────────────────────────────────────────────
async def upload_to_comfy(data: bytes, filename: str) -> str:
async with httpx.AsyncClient(timeout=30) as client:
async with comfy_client(timeout=30) as client:
r = await client.post(f"{COMFY}/upload/image",
files={"image": (filename, data, "image/jpeg")},
data={"overwrite": "true"})
@@ -82,11 +127,11 @@ async def upload_to_comfy(data: bytes, filename: str) -> str:
return r.json()["name"]
def build_workflow(img_name: str, expanded: "ExpandedPrompt") -> dict:
def build_workflow(img_name: str, expanded: "ExpandedPrompt", ckpt_name: str) -> dict:
"""Build ComfyUI API workflow from an ExpandedPrompt result."""
return {
"1": {"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": "realvisxlV50_v50LightningBakedvae.safetensors"}},
"inputs": {"ckpt_name": ckpt_name}},
"2": {"class_type": "LoadImage",
"inputs": {"image": img_name, "upload": "image"}},
"3": {"class_type": "CLIPTextEncode", # Positive prompt
@@ -115,16 +160,22 @@ def build_workflow(img_name: str, expanded: "ExpandedPrompt") -> dict:
async def queue_prompt(workflow: dict) -> str:
async with httpx.AsyncClient(timeout=30) as client:
async with comfy_client(timeout=30) as client:
r = await client.post(f"{COMFY}/prompt",
json={"prompt": workflow, "client_id": str(uuid.uuid4())})
r.raise_for_status()
if r.status_code >= 400:
detail = r.text
try:
detail = json.dumps(r.json())
except Exception:
pass
raise HTTPException(status_code=502, detail=f"ComfyUI rejected Dream Weaver workflow: {detail}")
return r.json()["prompt_id"]
async def poll_result(prompt_id: str, timeout: int = 300):
start = time.time()
async with httpx.AsyncClient(timeout=10) as client:
async with comfy_client(timeout=10) as client:
while time.time() - start < timeout:
r = await client.get(f"{COMFY}/history/{prompt_id}")
if r.status_code == 200:
@@ -152,19 +203,32 @@ async def background_poll(job_id: str, prompt_id: str):
@app.get("/health")
async def health():
comfy_ok = False
checkpoints: list[str] = []
try:
async with httpx.AsyncClient(timeout=5) as c:
async with comfy_client(timeout=5) as c:
r = await c.get(f"{COMFY}/system_stats")
comfy_ok = r.status_code == 200
except Exception:
pass
if comfy_ok:
try:
checkpoints = await list_comfy_checkpoints()
except Exception:
checkpoints = []
return {
"status": "ok",
"comfyui": comfy_ok,
"gpu": "4x NVIDIA L4 (96GB VRAM)",
"model": "RealVisXL V5.0 Lightning",
"comfyui_url": COMFY,
"checkpoint_ready": bool(checkpoints),
"checkpoint_count": len(checkpoints),
"preferred_checkpoints": PREFERRED_CHECKPOINTS,
"available_checkpoints": checkpoints[:12],
"llm_expansion": LLM_AVAILABLE,
"version": "2.0.0"
"version": "2.0.0",
"auth_required": GATEWAY_API_KEY is not None,
"auth_scheme": "x-dream-weaver-api-key"
}
@@ -186,7 +250,8 @@ async def room_types():
@app.post("/dream-weaver/expand", response_model=ExpandResponse)
async def expand_endpoint(req: ExpandRequest):
async def expand_endpoint(req: ExpandRequest, request: Request):
ensure_gateway_auth(request)
"""
Preview the LLM-generated prompt WITHOUT submitting to ComfyUI.
Use this to let the user review/edit the prompt before generating.
@@ -229,6 +294,7 @@ async def expand_endpoint(req: ExpandRequest):
@app.post("/dream-weaver")
async def dream_weaver(
request: Request,
image: UploadFile = File(...),
# ── Dynamic keyword mode (new) ──
keywords: str = Form(default=""), # comma-separated: "blue marble, gold, renaissance"
@@ -249,6 +315,7 @@ async def dream_weaver(
Returns job_id for async polling.
"""
ensure_gateway_auth(request)
job_id = str(uuid.uuid4())
jobs[job_id] = {"status": "uploading", "created": time.time()}
@@ -313,7 +380,9 @@ async def dream_weaver(
})
# Submit workflow
wf = build_workflow(comfy_name, expanded)
ckpt_name = await resolve_checkpoint()
jobs[job_id]["checkpoint"] = ckpt_name
wf = build_workflow(comfy_name, expanded, ckpt_name)
prompt_id = await queue_prompt(wf)
jobs[job_id].update({"status": "processing", "prompt_id": prompt_id})
@@ -340,7 +409,8 @@ async def dream_weaver(
@app.get("/dream-weaver/status/{job_id}")
async def status(job_id: str):
async def status(job_id: str, request: Request):
ensure_gateway_auth(request)
job = jobs.get(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
@@ -352,14 +422,15 @@ async def status(job_id: str):
@app.get("/dream-weaver/result/{job_id}")
async def result(job_id: str):
async def result(job_id: str, request: Request):
ensure_gateway_auth(request)
job = jobs.get(job_id)
if not job or job.get("status") != "done":
raise HTTPException(status_code=404, detail="Result not ready")
img = job["output"]
url = (f"{COMFY}/view?filename={img['filename']}"
f"&subfolder={img.get('subfolder','')}&type={img.get('type','output')}")
async with httpx.AsyncClient(timeout=30) as c:
async with comfy_client(timeout=30) as c:
r = await c.get(url)
return StreamingResponse(
io.BytesIO(r.content),
@@ -370,6 +441,7 @@ async def result(job_id: str):
@app.post("/dream-weaver/sync")
async def dream_weaver_sync(
request: Request,
image: UploadFile = File(...),
keywords: str = Form(default=""),
room_type: str = Form(default="living_room"),
@@ -381,6 +453,7 @@ async def dream_weaver_sync(
Blocking version — waits up to 120s and returns image bytes directly.
Use for testing. Prefer async /dream-weaver for production.
"""
ensure_gateway_auth(request)
data = await image.read()
filename = f"sync_{uuid.uuid4().hex[:8]}_{image.filename or 'room.jpg'}"
comfy_name = await upload_to_comfy(data, filename)
@@ -404,14 +477,15 @@ async def dream_weaver_sync(
else:
raise HTTPException(status_code=400, detail="Provide keywords or custom_positive")
wf = build_workflow(comfy_name, expanded)
ckpt_name = await resolve_checkpoint()
wf = build_workflow(comfy_name, expanded, ckpt_name)
prompt_id = await queue_prompt(wf)
img, err = await poll_result(prompt_id, timeout=120)
if err:
raise HTTPException(status_code=500, detail=str(err))
url = (f"{COMFY}/view?filename={img['filename']}"
f"&subfolder={img.get('subfolder','')}&type={img.get('type','output')}")
async with httpx.AsyncClient(timeout=30) as c:
async with comfy_client(timeout=30) as c:
r = await c.get(url)
return StreamingResponse(io.BytesIO(r.content), media_type="image/png",
headers={"X-Style": expanded.style_name,

View File

@@ -0,0 +1,49 @@
from __future__ import annotations
import os
from typing import Mapping
_SUPPORTED_ENV_KEYS = (
"DREAM_WEAVER_GATEWAY_API_KEY",
"DREAM_WEAVER_API_KEY",
)
def load_gateway_api_key(env: Mapping[str, str] | None = None) -> str | None:
values = env if env is not None else os.environ
for key in _SUPPORTED_ENV_KEYS:
raw = values.get(key)
if raw is None:
continue
trimmed = raw.strip()
if trimmed:
return trimmed
return None
def extract_gateway_api_key(headers: Mapping[str, str]) -> str | None:
for header_name in ("x-dream-weaver-api-key", "x-api-key"):
value = headers.get(header_name)
if value:
trimmed = value.strip()
if trimmed:
return trimmed
authorization = headers.get("authorization", "")
if authorization.lower().startswith("bearer "):
token = authorization[7:].strip()
if token:
return token
return None
def is_gateway_request_authorized(
headers: Mapping[str, str],
required_api_key: str | None,
) -> bool:
if required_api_key is None:
return True
presented = extract_gateway_api_key(headers)
return presented == required_api_key

View File

@@ -4,38 +4,116 @@ from pathlib import Path
from typing import Optional, List
import httpx
import uvicorn
from fastapi import FastAPI, UploadFile, File, HTTPException, Form, BackgroundTasks
from fastapi import FastAPI, UploadFile, File, HTTPException, Form, Request
from fastapi.responses import JSONResponse, StreamingResponse
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
SCRIPTS_DIR = Path(__file__).parent / "scripts"
ROOT_DIR = Path(__file__).resolve().parent
SCRIPTS_DIR = ROOT_DIR / "scripts"
if not SCRIPTS_DIR.exists():
SCRIPTS_DIR = ROOT_DIR / "comfy_engine" / "scripts"
sys.path.insert(0, str(SCRIPTS_DIR))
try:
from prompt_expander import expand_prompt, expand_prompt_simple, ROOM_CONTEXTS, ExpandedPrompt
from gateway_auth import load_gateway_api_key, is_gateway_request_authorized
LLM_AVAILABLE = True
except ImportError:
LLM_AVAILABLE = False
logging.warning("prompt_expander not found — LLM expansion disabled")
class ExpandedPrompt(BaseModel):
style_name: str
positive_prompt: str
negative_prompt: str
steps: int = 28
cfg: float = 7.0
denoise: float = 0.72
ROOM_CONTEXTS = {}
def expand_prompt(keywords: List[str], room_type: str) -> ExpandedPrompt:
pretty_room = room_type.replace("_", " ").strip() or "living room"
pretty_keywords = ", ".join(keywords) if keywords else "modern, photorealistic"
return ExpandedPrompt(
style_name="Fallback Prompt Expansion",
positive_prompt=(
f"photorealistic premium {pretty_room} interior design, {pretty_keywords}, "
"natural lighting, realistic materials, architect-grade composition"
),
negative_prompt=(
"worst quality, low quality, blurry, distorted perspective, "
"people, watermark, text, duplicate objects"
),
)
expand_prompt_simple = expand_prompt
from gateway_auth import load_gateway_api_key, is_gateway_request_authorized
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger("DreamWeaverGateway")
COMFY = "http://127.0.0.1:8188"
COMFY = (os.environ.get("COMFYUI_URL") or os.environ.get("COMFY_URL") or "http://127.0.0.1:8188").rstrip("/")
COMFY_TLS_VERIFY = os.environ.get("COMFYUI_TLS_VERIFY", "true").strip().lower() not in {"0", "false", "no", "off"}
GATEWAY_API_KEY = load_gateway_api_key()
PREFERRED_CHECKPOINTS = [
"realvisxlV50_v50LightningBakedvae.safetensors",
"realvisxlV50Lightning_v50Lightning.safetensors",
]
app = FastAPI(title="Dream Weaver API v2", version="2.0.0")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
jobs: dict = {}
def comfy_client(timeout: float = 30) -> httpx.AsyncClient:
return httpx.AsyncClient(timeout=timeout, verify=COMFY_TLS_VERIFY, follow_redirects=True)
async def list_comfy_checkpoints() -> list[str]:
async with comfy_client(timeout=10) as client:
response = await client.get(f"{COMFY}/models/checkpoints")
response.raise_for_status()
payload = response.json()
if isinstance(payload, list):
return [item for item in payload if isinstance(item, str)]
return []
async def resolve_checkpoint() -> str:
checkpoints = await list_comfy_checkpoints()
if not checkpoints:
raise HTTPException(
status_code=503,
detail=(
"ComfyUI is online but has no checkpoint models installed. "
"Hydrate RealVisXL into ComfyUI/models/checkpoints before generating."
),
)
lower_lookup = {item.lower(): item for item in checkpoints}
for preferred in PREFERRED_CHECKPOINTS:
match = lower_lookup.get(preferred.lower())
if match:
return match
return checkpoints[0]
def gateway_urls(job_id: str) -> dict:
return {
"poll_url": f"/dream-weaver/status/{job_id}",
"result_url": f"/dream-weaver/result/{job_id}",
}
def ensure_gateway_auth(request: Request) -> None:
if is_gateway_request_authorized(request.headers, GATEWAY_API_KEY):
return
raise HTTPException(status_code=401, detail="Dream Weaver gateway API key is required or invalid.")
async def upload_to_comfy(data: bytes, filename: str) -> str:
async with httpx.AsyncClient(timeout=30) as client:
async with comfy_client(timeout=30) as client:
r = await client.post(f"{COMFY}/upload/image", files={"image": (filename, data, "image/jpeg")}, data={"overwrite": "true"})
r.raise_for_status()
return r.json()["name"]
def build_workflow(img_name: str, expanded: "ExpandedPrompt") -> dict:
def build_workflow(img_name: str, expanded: "ExpandedPrompt", ckpt_name: str) -> dict:
return {
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "realvisxlV50_v50LightningBakedvae.safetensors"}},
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": ckpt_name}},
"2": {"class_type": "LoadImage", "inputs": {"image": img_name, "upload": "image"}},
"3": {"class_type": "CLIPTextEncode", "inputs": {"text": expanded.positive_prompt, "clip": ["1", 1]}},
"4": {"class_type": "CLIPTextEncode", "inputs": {"text": expanded.negative_prompt, "clip": ["1", 1]}},
@@ -46,14 +124,20 @@ def build_workflow(img_name: str, expanded: "ExpandedPrompt") -> dict:
}
async def queue_prompt(workflow: dict) -> str:
async with httpx.AsyncClient(timeout=30) as client:
async with comfy_client(timeout=30) as client:
r = await client.post(f"{COMFY}/prompt", json={"prompt": workflow, "client_id": str(uuid.uuid4())})
r.raise_for_status()
if r.status_code >= 400:
detail = r.text
try:
detail = json.dumps(r.json())
except Exception:
pass
raise HTTPException(status_code=502, detail=f"ComfyUI rejected Dream Weaver workflow: {detail}")
return r.json()["prompt_id"]
async def poll_result(prompt_id: str, timeout: int = 300):
start = time.time()
async with httpx.AsyncClient(timeout=10) as client:
async with comfy_client(timeout=10) as client:
while time.time() - start < timeout:
r = await client.get(f"{COMFY}/history/{prompt_id}")
if r.status_code == 200:
@@ -70,29 +154,89 @@ async def background_poll(job_id: str, prompt_id: str):
@app.get("/health")
async def health():
return {"status": "ok", "comfyui": True, "llm_expansion": LLM_AVAILABLE, "version": "2.0.0"}
checkpoints: list[str] = []
try:
checkpoints = await list_comfy_checkpoints()
except Exception:
checkpoints = []
return {
"status": "ok",
"comfyui": True,
"comfyui_url": COMFY,
"checkpoint_ready": bool(checkpoints),
"checkpoint_count": len(checkpoints),
"preferred_checkpoints": PREFERRED_CHECKPOINTS,
"available_checkpoints": checkpoints[:12],
"llm_expansion": LLM_AVAILABLE,
"version": "2.0.0",
"auth_required": GATEWAY_API_KEY is not None,
"auth_scheme": "x-dream-weaver-api-key"
}
@app.get("/dream-weaver/status/{job_id}")
async def status(job_id: str):
async def status(job_id: str, request: Request):
ensure_gateway_auth(request)
job = jobs.get(job_id)
if not job: raise HTTPException(status_code=404, detail="Job not found")
res = {k: v for k, v in job.items() if k != "output"}
res["ready"] = job.get("status") == "done"
if res["ready"]:
res.update(gateway_urls(job_id))
return res
@app.post("/dream-weaver")
async def dream_weaver(image: UploadFile = File(...), keywords: str = Form(default=""), room_type: str = Form(default="living_room")):
async def dream_weaver(
request: Request,
image: UploadFile = File(...),
keywords: str = Form(default=""),
room_type: str = Form(default="living_room")
):
ensure_gateway_auth(request)
job_id = str(uuid.uuid4())
jobs[job_id] = {"status": "uploading", "created": time.time()}
data = await image.read()
comfy_name = await upload_to_comfy(data, f"dw_{job_id[:8]}.jpg")
kw_list = [k.strip() for k in keywords.split(",") if k.strip()]
expanded = await asyncio.to_thread(expand_prompt, keywords=kw_list, room_type=room_type)
wf = build_workflow(comfy_name, expanded)
ckpt_name = await resolve_checkpoint()
jobs[job_id]["checkpoint"] = ckpt_name
wf = build_workflow(comfy_name, expanded, ckpt_name)
prompt_id = await queue_prompt(wf)
jobs[job_id].update({"status": "processing", "prompt_id": prompt_id})
asyncio.create_task(background_poll(job_id, prompt_id))
return {"job_id": job_id, "status": "processing"}
return {
"job_id": job_id,
"status": "processing",
**gateway_urls(job_id),
}
@app.get("/dream-weaver/result/{job_id}")
async def result(job_id: str, request: Request):
ensure_gateway_auth(request)
job = jobs.get(job_id)
if not job or job.get("status") != "done":
raise HTTPException(status_code=404, detail="Result not ready")
img = job.get("output")
if not img:
raise HTTPException(status_code=404, detail="Result not ready")
async with comfy_client(timeout=30) as client:
response = await client.get(
f"{COMFY}/view",
params={
"filename": img["filename"],
"subfolder": img.get("subfolder", ""),
"type": img.get("type", "output"),
},
)
response.raise_for_status()
return StreamingResponse(
io.BytesIO(response.content),
media_type="image/png",
headers={"Content-Disposition": f"attachment; filename=dreamweaver_{job_id[:8]}.png"},
)
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8082, log_level="info")

View File

@@ -1,181 +0,0 @@
import SwiftUI
enum AppSection: String, CaseIterable, Hashable, Identifiable {
var id: String { rawValue }
case dashboard = "Dashboard"
case communications = "Communications"
case calendar = "Calendar"
case oracle = "Oracle"
case sentinel = "Sentinel"
case inventory = "Inventory"
case settings = "Settings"
var systemImage: String {
switch self {
case .dashboard: return "square.grid.2x2"
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"
}
}
var accentColor: Color {
switch self {
case .dashboard: return VelocityTheme.accent
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
}
}
}
struct ContentView: View {
@State private var selectedSection: AppSection? = .dashboard
var body: some View {
NavigationSplitView(columnVisibility: .constant(.all)) {
sidebarContent
} detail: {
detailContent
}
.navigationSplitViewStyle(.balanced)
}
// 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()
}
.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
SidebarRow(section: section,
isSelected: selectedSection == section)
.onTapGesture { selectedSection = section }
}
}
.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("AF")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(.white)
}
VStack(alignment: .leading, spacing: 2) {
Text("Ahmed Al-Farsi")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(VelocityTheme.foreground)
Text("Sales Director")
.font(.system(size: 10))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
}
.padding(16)
}
}
.navigationTitle("")
.toolbar(.hidden, for: .navigationBar)
}
// MARK: Detail
private var detailContent: some View {
ZStack {
VelocityTheme.background.ignoresSafeArea()
Group {
switch selectedSection {
case .dashboard: DashboardView()
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)
}
}
}
// MARK: Sidebar Row
private struct SidebarRow: View {
let section: AppSection
let isSelected: Bool
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.rawValue)
.font(.system(size: 14, weight: isSelected ? .semibold : .regular))
.foregroundStyle(isSelected ? VelocityTheme.foreground : VelocityTheme.mutedFg)
Spacer()
}
.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)
)
)
.contentShape(Rectangle())
}
}
#Preview {
ContentView()
}

View File

@@ -1,19 +0,0 @@
import Foundation
enum AppConfig {
private static func value(for key: String) -> String? {
guard let raw = Bundle.main.infoDictionary?[key] as? String else {
return nil
}
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty || trimmed == "$(\(key))" {
return nil
}
return trimmed
}
static let baseURL: String = value(for: "BASE_URL") ?? "http://54.91.19.60:8082"
static let apiEmail: String? = value(for: "API_EMAIL")
static let apiPassword: String? = value(for: "API_PASSWORD")
static let apiBearerToken: String? = value(for: "API_BEARER_TOKEN")
}

View File

@@ -1,92 +0,0 @@
import CoreLocation
import Foundation
struct SunPosition {
let azimuth: Double // 0...360, degrees clockwise from true north
let elevation: Double // -90...90 degrees above horizon
}
enum SunMath {
static func calculateSunPosition(date: Date, coordinate: CLLocationCoordinate2D) -> SunPosition {
let timezone = TimeZone.current
let localOffsetHours = Double(timezone.secondsFromGMT(for: date)) / 3600.0
let julianDay = date.julianDay
let n = julianDay - 2_451_545.0
let meanLongitude = normalizeDegrees(280.46 + 0.985_647_4 * n)
let meanAnomaly = normalizeDegrees(357.528 + 0.985_600_3 * n)
let lambda = meanLongitude
+ 1.915 * sin(meanAnomaly.radians)
+ 0.020 * sin((2.0 * meanAnomaly).radians)
let obliquity = 23.439 - 0.000_000_4 * n
let rightAscension = atan2(
cos(obliquity.radians) * sin(lambda.radians),
cos(lambda.radians)
).degrees
let declination = asin(sin(obliquity.radians) * sin(lambda.radians)).degrees
let utcHours = date.utcHours
let lst = normalizeDegrees(100.46 + 0.985_647 * n + coordinate.longitude + 15.0 * utcHours + localOffsetHours)
let hourAngle = normalizeDegrees(lst - rightAscension)
let signedHourAngle = hourAngle > 180.0 ? hourAngle - 360.0 : hourAngle
let latitude = coordinate.latitude.radians
let declinationRad = declination.radians
let hourAngleRad = signedHourAngle.radians
let elevation = asin(
sin(latitude) * sin(declinationRad)
+ cos(latitude) * cos(declinationRad) * cos(hourAngleRad)
).degrees
let azimuth = normalizeDegrees(
atan2(
-sin(hourAngleRad),
tan(declinationRad) * cos(latitude) - sin(latitude) * cos(hourAngleRad)
).degrees
)
return SunPosition(azimuth: azimuth, elevation: elevation)
}
static func sunPathSamples(for date: Date, coordinate: CLLocationCoordinate2D) -> [Date: SunPosition] {
let calendar = Calendar.current
let sampleHours = [8, 10, 12, 14, 16]
var output: [Date: SunPosition] = [:]
for hour in sampleHours {
if let sampleDate = calendar.date(bySettingHour: hour, minute: 0, second: 0, of: date) {
output[sampleDate] = calculateSunPosition(date: sampleDate, coordinate: coordinate)
}
}
return output
}
private static func normalizeDegrees(_ value: Double) -> Double {
let reduced = value.truncatingRemainder(dividingBy: 360.0)
return reduced >= 0 ? reduced : reduced + 360.0
}
}
private extension Date {
var utcHours: Double {
let calendar = Calendar(identifier: .gregorian)
let comps = calendar.dateComponents(in: TimeZone(secondsFromGMT: 0)!, from: self)
let hours = Double(comps.hour ?? 0)
let minutes = Double(comps.minute ?? 0)
let seconds = Double(comps.second ?? 0)
return hours + minutes / 60.0 + seconds / 3600.0
}
var julianDay: Double {
let interval = timeIntervalSince1970
return (interval / 86_400.0) + 2_440_587.5
}
}
private extension Double {
var radians: Double { self * .pi / 180.0 }
var degrees: Double { self * 180.0 / .pi }
}

View File

@@ -1,100 +0,0 @@
import Foundation
import UIKit
@preconcurrency import Alamofire
final class ComfyClient {
static let shared = ComfyClient()
private let endpoint = "http://192.168.x.x:8000/dream-weaver"
private let session: Session
private init(session: Session = .default) {
self.session = session
}
func generateImage(source: UIImage, prompt: String) async throws -> UIImage {
let resized = source.resizedSquare(to: 1024)
guard let imageData = resized.jpegData(compressionQuality: 0.9) else {
throw ComfyClientError.encodingFailed
}
let payload = DreamWeaverRequest(
imageBase64: imageData.base64EncodedString(),
prompt: prompt
)
let response = try await session.request(
endpoint,
method: .post,
parameters: payload,
encoder: JSONParameterEncoder.default,
headers: [.contentType("application/json")]
)
.validate(statusCode: 200..<300)
.serializingDecodable(DreamWeaverResponse.self)
.value
guard
let data = Data(base64Encoded: response.outputBase64),
let generated = UIImage(data: data)
else {
throw ComfyClientError.decodingFailed
}
return generated
}
}
private struct DreamWeaverRequest: Encodable, Sendable {
let imageBase64: String
let prompt: String
}
private struct DreamWeaverResponse: Decodable, Sendable {
let outputBase64: String
enum CodingKeys: String, CodingKey {
case outputBase64 = "output_base64"
case imageBase64 = "image_base64"
case image
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
if let preferred = try container.decodeIfPresent(String.self, forKey: .outputBase64) {
outputBase64 = preferred
return
}
if let legacy = try container.decodeIfPresent(String.self, forKey: .imageBase64) {
outputBase64 = legacy
return
}
outputBase64 = try container.decode(String.self, forKey: .image)
}
}
enum ComfyClientError: Error {
case encodingFailed
case decodingFailed
}
private extension UIImage {
func resizedSquare(to side: CGFloat) -> UIImage {
let format = UIGraphicsImageRendererFormat.default()
format.scale = 1
let renderer = UIGraphicsImageRenderer(size: CGSize(width: side, height: side), format: format)
return renderer.image { _ in
let aspect = size.width / size.height
let targetRect: CGRect
if aspect > 1 {
let width = side * aspect
targetRect = CGRect(x: (side - width) / 2, y: 0, width: width, height: side)
} else {
let height = side / aspect
targetRect = CGRect(x: 0, y: (side - height) / 2, width: side, height: height)
}
draw(in: targetRect)
}
}
}

View File

@@ -1,258 +0,0 @@
import Foundation
struct VelocityLeadDTO: Decodable, Identifiable {
let id: String
let name: String
let phone: String?
let source: String
let qualification: String
let score: Int
let kanbanStatus: String
let budget: String
let unitInterest: String
let createdAt: String?
let updatedAt: String?
enum CodingKeys: String, CodingKey {
case id
case name
case phone
case source
case qualification
case score
case kanbanStatus = "kanban_status"
case budget
case unitInterest = "unit_interest"
case createdAt = "created_at"
case updatedAt = "updated_at"
}
}
struct VelocityCommunicationEventDTO: Decodable, Identifiable {
let eventId: String
let leadId: String
let channel: String
let direction: String
let provider: String?
let captureMode: String
let consentState: String
let timestamp: String
let durationSeconds: Int?
let summary: String?
let recordingRef: String?
let createdAt: String
var id: String { eventId }
enum CodingKeys: String, CodingKey {
case eventId = "event_id"
case leadId = "lead_id"
case channel
case direction
case provider
case captureMode = "capture_mode"
case consentState = "consent_state"
case timestamp
case durationSeconds = "duration_seconds"
case summary
case recordingRef = "recording_ref"
case createdAt = "created_at"
}
}
struct VelocityCalendarEventDTO: Decodable, Identifiable {
let calendarEventId: String
let leadId: String?
let title: String
let description: String?
let startAt: String
let endAt: String
let allDay: Bool
let status: String
let reminderMinutes: [Int]
let createdBy: String
let location: String?
let createdAt: String
var id: String { calendarEventId }
enum CodingKeys: String, CodingKey {
case calendarEventId = "calendar_event_id"
case leadId = "lead_id"
case title
case description
case startAt = "start_at"
case endAt = "end_at"
case allDay = "all_day"
case status
case reminderMinutes = "reminder_minutes"
case createdBy = "created_by"
case location
case createdAt = "created_at"
}
}
struct VelocityAlertSnapshotDTO: Decodable {
let pendingInsights: Int
let upcomingCalendarEvents24h: Int
let pendingTranscriptions: Int
let generatedAt: String
enum CodingKeys: String, CodingKey {
case pendingInsights = "pending_insights"
case upcomingCalendarEvents24h = "upcoming_calendar_events_24h"
case pendingTranscriptions = "pending_transcriptions"
case generatedAt = "generated_at"
}
}
enum VelocityAPIError: LocalizedError {
case notConfigured(String)
case invalidResponse
case api(String)
var errorDescription: String? {
switch self {
case .notConfigured(let message):
return message
case .invalidResponse:
return "Velocity backend returned an invalid response."
case .api(let message):
return message
}
}
}
actor VelocityAPIClient {
static let shared = VelocityAPIClient()
private struct LoginBody: Encodable {
let email: String
let password: String
}
private struct LoginResponse: Decodable {
let accessToken: String
enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
}
}
private struct LeadsEnvelope: Decodable {
let data: [VelocityLeadDTO]
}
private struct EventsEnvelope: Decodable {
let events: [VelocityCommunicationEventDTO]
}
private struct CalendarEnvelope: Decodable {
let events: [VelocityCalendarEventDTO]
}
private let decoder = JSONDecoder()
private var cachedToken: String?
func fetchLeads() async throws -> [VelocityLeadDTO] {
let request = try await authorizedRequest(path: "/api/leads")
let response: LeadsEnvelope = try await perform(request)
return response.data
}
func fetchEvents(for leadId: String, limit: Int = 5) async throws -> [VelocityCommunicationEventDTO] {
let query = URLQueryItem(name: "lead_id", value: leadId)
let limitItem = URLQueryItem(name: "limit", value: String(limit))
let request = try await authorizedRequest(path: "/api/mobile-edge/events", queryItems: [query, limitItem])
let response: EventsEnvelope = try await perform(request)
return response.events
}
func fetchCalendarEvents(limit: Int = 50) async throws -> [VelocityCalendarEventDTO] {
let request = try await authorizedRequest(
path: "/api/mobile-edge/calendar",
queryItems: [URLQueryItem(name: "limit", value: String(limit))]
)
let response: CalendarEnvelope = try await perform(request)
return response.events
}
func fetchAlerts() async throws -> VelocityAlertSnapshotDTO {
let request = try await authorizedRequest(path: "/api/mobile-edge/alerts")
return try await perform(request)
}
private func authorizedRequest(path: String, queryItems: [URLQueryItem] = []) async throws -> URLRequest {
guard let url = buildURL(path: path, queryItems: queryItems) else {
throw VelocityAPIError.notConfigured("Velocity backend base URL is invalid.")
}
let token = try await getToken()
var request = URLRequest(url: url)
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.timeoutInterval = 30
return request
}
private func buildURL(path: String, queryItems: [URLQueryItem]) -> URL? {
guard var components = URLComponents(string: AppConfig.baseURL) else {
return nil
}
components.path = path
if !queryItems.isEmpty {
components.queryItems = queryItems
}
return components.url
}
private func getToken() async throws -> String {
if let token = AppConfig.apiBearerToken {
return token
}
if let token = cachedToken {
return token
}
guard let email = AppConfig.apiEmail, let password = AppConfig.apiPassword else {
throw VelocityAPIError.notConfigured(
"Set API_BEARER_TOKEN or API_EMAIL/API_PASSWORD in the app configuration to use live Velocity data."
)
}
guard let loginURL = buildURL(path: "/api/auth/login", queryItems: []) else {
throw VelocityAPIError.notConfigured("Velocity backend base URL is invalid.")
}
var request = URLRequest(url: loginURL)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.httpBody = try JSONEncoder().encode(LoginBody(email: email, password: password))
request.timeoutInterval = 30
let response: LoginResponse = try await perform(request)
cachedToken = response.accessToken
return response.accessToken
}
private func perform<T: Decodable>(_ request: URLRequest) async throws -> T {
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw VelocityAPIError.invalidResponse
}
guard 200..<300 ~= http.statusCode else {
if let apiError = try? decoder.decode(APIErrorPayload.self, from: data), let detail = apiError.detail {
throw VelocityAPIError.api(detail)
}
throw VelocityAPIError.api("Velocity request failed with HTTP \(http.statusCode).")
}
do {
return try decoder.decode(T.self, from: data)
} catch {
throw VelocityAPIError.invalidResponse
}
}
}
private struct APIErrorPayload: Decodable {
let detail: String?
}

View File

@@ -1,256 +0,0 @@
import SwiftUI
import Combine
// MARK: Data Models
enum SentimentType: String, CaseIterable {
case excited, interested, neutral, confused, disinterested
var score: Int {
switch self {
case .excited: return 100
case .interested: return 80
case .neutral: return 50
case .confused: return 30
case .disinterested: return 10
}
}
var emoji: String {
switch self {
case .excited: return "😃"
case .interested: return "🤔"
case .neutral: return "😐"
case .confused: return "😕"
case .disinterested: return "😴"
}
}
var color: Color {
switch self {
case .excited: return VelocityTheme.success
case .interested: return VelocityTheme.accent
case .neutral: return VelocityTheme.mutedFg
case .confused: return VelocityTheme.warning
case .disinterested: return VelocityTheme.danger
}
}
}
struct Visitor: Identifiable {
let id: String
let faceId: String
var sentiment: SentimentType
var confidence: Double
var dwellTime: Int // seconds
var zone: String
let timestamp: Date
}
enum LeadSource: String {
case whatsapp = "WhatsApp"
case walkin = "Walk-in"
case website = "Website"
}
enum LeadStatus: String {
case hot = "Hot"
case engaged = "Engaged"
case new = "New"
case qualified = "Qualified"
case closed = "Closed"
var color: Color {
switch self {
case .hot: return VelocityTheme.danger
case .engaged: return VelocityTheme.accent
case .new: return VelocityTheme.mutedFg
case .qualified: return VelocityTheme.success
case .closed: return Color(red: 0.60, green: 0.57, blue: 0.99)
}
}
}
struct Lead: Identifiable {
let id: String
let name: String
let phone: String
let source: LeadSource
var status: LeadStatus
var lastMessage: String
var lastActive: Date
var unreadCount: Int
let qualification: String
let budget: String
let interest: String
var initials: String { String(name.split(separator: " ").prefix(2).compactMap(\.first)) }
}
struct ChatMessage: Identifiable {
let id: String
let sender: String // "user" | "oracle" | "ai"
let content: String
let timestamp: Date
}
struct SystemHealth {
var cpu: Double // 01
var gpu: Double
var memory: Double
}
struct DashboardMetrics {
var activeVisitors: Int
var revenue: String
var aiJobs: Int
var dailyVisitors: Int
var sentimentScore: Double // 0100
var systemHealth: SystemHealth
}
// MARK: Shared Store
@Observable
final class AppStore {
static let shared = AppStore()
private init() { startTimer() }
// Dashboard
var metrics = DashboardMetrics(
activeVisitors: 17,
revenue: "$3.2M",
aiJobs: 24,
dailyVisitors: 128,
sentimentScore: 78,
systemHealth: SystemHealth(cpu: 0.42, gpu: 0.61, memory: 0.55)
)
var dashboardMessages: [ChatMessage] = [
ChatMessage(id: "d0", sender: "ai",
content: "Hello, Ahmed. I've analysed the Q3 pipeline. Would you like a refined strategy for the Apex Innovations deal?",
timestamp: Date().addingTimeInterval(-300))
]
var isDashboardThinking = false
// Visitors
var visitors: [Visitor] = [
Visitor(id: "v1", faceId: "face_001", sentiment: .excited, confidence: 0.92, dwellTime: 450, zone: "Penthouse Show", timestamp: Date()),
Visitor(id: "v2", faceId: "face_002", sentiment: .interested, confidence: 0.87, dwellTime: 320, zone: "Amenity Deck VR", timestamp: Date()),
Visitor(id: "v3", faceId: "face_003", sentiment: .neutral, confidence: 0.78, dwellTime: 180, zone: "Reception", timestamp: Date()),
Visitor(id: "v4", faceId: "face_004", sentiment: .confused, confidence: 0.74, dwellTime: 95, zone: "Penthouse Show", timestamp: Date()),
Visitor(id: "v5", faceId: "face_005", sentiment: .disinterested, confidence: 0.65, dwellTime: 60, zone: "Gallery", timestamp: Date()),
]
// Alerts
var isAlertActive = false
var alertMessage = ""
func triggerAlert(_ msg: String) {
isAlertActive = true
alertMessage = msg
}
func clearAlert() {
isAlertActive = false
alertMessage = ""
}
// Leads (Oracle)
var leads: [Lead] = [
Lead(id: "1", name: "Mohammed Al-Rashid", phone: "+971 55 123 4567", source: .whatsapp,
status: .hot, lastMessage: "Can we schedule a viewing for the penthouse tomorrow?",
lastActive: Date().addingTimeInterval(-300), unreadCount: 2,
qualification: "whale", budget: "AED 15M+", interest: "Penthouse Suite"),
Lead(id: "2", name: "Sarah Chen", phone: "+971 50 987 6543", source: .walkin,
status: .engaged, lastMessage: "Thank you for the brochure. I will review with my partner.",
lastActive: Date().addingTimeInterval(-1800), unreadCount: 0,
qualification: "potential", budget: "AED 58M", interest: "2BR Sea View"),
Lead(id: "3", name: "James Wilson", phone: "+971 52 456 7890", source: .website,
status: .new, lastMessage: "Interested in investment opportunities.",
lastActive: Date().addingTimeInterval(-7200), unreadCount: 1,
qualification: "potential", budget: "AED 35M", interest: "1BR Investment"),
Lead(id: "4", name: "Fatima Hassan", phone: "+971 54 321 0987", source: .whatsapp,
status: .qualified,lastMessage: "What are the payment plan options?",
lastActive: Date().addingTimeInterval(-14400), unreadCount: 0,
qualification: "whale", budget: "AED 12M+", interest: "3BR + Maid"),
Lead(id: "5", name: "David Kumar", phone: "+971 56 789 0123", source: .walkin,
status: .closed, lastMessage: "Contract signed. Thank you!",
lastActive: Date().addingTimeInterval(-86400), unreadCount: 0,
qualification: "whale", budget: "AED 20M", interest: "Full Floor"),
]
var messages: [String: [ChatMessage]] = [
"1": [
ChatMessage(id: "m1", sender: "user", content: "Hi, I am interested in the penthouse units.",
timestamp: Date().addingTimeInterval(-7200)),
ChatMessage(id: "m2", sender: "oracle",
content: "Welcome! Our penthouse collection features 4 exclusive units with panoramic sea views. Prices start at AED 15M.",
timestamp: Date().addingTimeInterval(-7200 + 30)),
ChatMessage(id: "m3", sender: "user", content: "Can we schedule a viewing tomorrow?",
timestamp: Date().addingTimeInterval(-300)),
],
"2": [
ChatMessage(id: "m4", sender: "oracle",
content: "Hello Sarah! Here is the digital brochure for the 2-bedroom units we discussed.",
timestamp: Date().addingTimeInterval(-14400)),
ChatMessage(id: "m5", sender: "user", content: "Thank you. I will review with my partner.",
timestamp: Date().addingTimeInterval(-1800)),
],
]
var activeLeadId: String? = "1"
var isOracleThinking = false
func addDashboardMessage(sender: String, content: String) {
let msg = ChatMessage(id: UUID().uuidString, sender: sender, content: content, timestamp: Date())
dashboardMessages.append(msg)
}
func addOracleMessage(leadId: String, sender: String, content: String) {
let msg = ChatMessage(id: UUID().uuidString, sender: sender, content: content, timestamp: Date())
if messages[leadId] == nil { messages[leadId] = [] }
messages[leadId]!.append(msg)
}
// Live ticker
private var timerTask: AnyCancellable?
private var alertTask: DispatchWorkItem?
private func startTimer() {
timerTask = Timer.publish(every: 5, on: .main, in: .common)
.autoconnect()
.sink { [weak self] _ in self?.tick() }
}
private func tick() {
// jitter visitor count ±1
let delta = Int.random(in: -1...1)
metrics.activeVisitors = max(10, metrics.activeVisitors + delta)
// jitter sentiment ±2
let sDelta = Double.random(in: -2...2)
metrics.sentimentScore = min(100, max(40, metrics.sentimentScore + sDelta))
// jitter system health
metrics.systemHealth.cpu = Double.random(in: 0.30...0.65)
metrics.systemHealth.gpu = Double.random(in: 0.45...0.75)
metrics.systemHealth.memory = Double.random(in: 0.40...0.70)
// Random alert (same 10% chance as WebOS every tick)
if !isAlertActive && Double.random(in: 0...1) > 0.85 {
triggerAlert("Confusion detected in Zone B Penthouse Gallery")
let work = DispatchWorkItem { [weak self] in self?.clearAlert() }
alertTask = work
DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: work)
}
}
}
// MARK: Helpers
extension Date {
var relativeShort: String {
let diff = Int(Date().timeIntervalSince(self))
if diff < 60 { return "now" }
if diff < 3600 { return "\(diff / 60)m ago" }
if diff < 86400 { return "\(diff / 3600)h ago" }
return "\(diff / 86400)d ago"
}
}

View File

@@ -1,363 +0,0 @@
import SwiftUI
private struct CalendarAgendaItem: Identifiable {
let id: String
let title: String
let slot: String
let owner: String
let location: String
let type: String
let color: Color
}
private struct CalendarQuickMetric: Identifiable {
let id: String
let label: String
let value: String
let color: Color
}
struct CalendarView: View {
@State private var selectedDay = "Wednesday"
@State private var agendaItems: [CalendarAgendaItem] = []
@State private var calendarMetrics: [CalendarQuickMetric] = []
@State private var isLoading = true
@State private var errorMessage: String?
private let refreshTimer = Timer.publish(every: 15, on: .main, in: .common).autoconnect()
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
header
if let errorMessage {
errorBanner(errorMessage)
}
if isLoading {
loadingPanel
} else {
metricsRow
HStack(alignment: .top, spacing: 18) {
scheduleRail
agendaPanel
}
}
}
.padding(20)
}
.background(VelocityTheme.background)
.scrollContentBackground(.hidden)
.task { await loadCalendar() }
.refreshable { await loadCalendar() }
.onReceive(refreshTimer) { _ in
Task { await loadCalendar(silent: true) }
}
}
private var header: some View {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
Text("Calendar")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text("Operator scheduling edge for follow-ups, tours, and legal milestones.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
Text("Live sync")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(VelocityTheme.accent)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(
Capsule()
.fill(VelocityTheme.accent.opacity(0.12))
.overlay(Capsule().stroke(VelocityTheme.borderAccent, lineWidth: 1))
)
}
}
private var metricsRow: some View {
HStack(spacing: 12) {
ForEach(calendarMetrics) { metric in
VStack(alignment: .leading, spacing: 8) {
Text(metric.label.uppercased())
.font(.system(size: 10, weight: .semibold))
.tracking(1)
.foregroundStyle(VelocityTheme.mutedFg)
Text(metric.value)
.font(.system(size: 20, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
RoundedRectangle(cornerRadius: 4)
.fill(metric.color)
.frame(width: 48, height: 4)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.glassCard(cornerRadius: 16)
}
}
}
private var scheduleRail: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Week Grid")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
ForEach(["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], id: \.self) { day in
Button {
selectedDay = day
} label: {
HStack {
VStack(alignment: .leading, spacing: 3) {
Text(day)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text(daySubtitle(day))
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
Circle()
.fill(selectedDay == day ? VelocityTheme.accent : VelocityTheme.borderSubtle)
.frame(width: 10, height: 10)
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(selectedDay == day ? VelocityTheme.accent.opacity(0.12) : VelocityTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(selectedDay == day ? VelocityTheme.borderAccent : VelocityTheme.borderSubtle, lineWidth: 1)
)
)
}
.buttonStyle(.plain)
}
}
.padding(18)
.frame(maxWidth: 300, alignment: .topLeading)
.glassCard(cornerRadius: 20)
}
private var agendaPanel: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(selectedDay)
.font(.system(size: 22, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text("Confirmed live schedule for the authenticated Velocity operator.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
}
if agendaItems.isEmpty {
Text("No live calendar events are scheduled yet for this user.")
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.mutedFg)
}
ForEach(filteredAgendaItems) { item in
HStack(alignment: .top, spacing: 14) {
VStack(spacing: 6) {
Circle()
.fill(item.color)
.frame(width: 12, height: 12)
Rectangle()
.fill(item.color.opacity(0.22))
.frame(width: 2, height: 44)
}
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(item.title)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Spacer()
Text(item.type)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(item.color)
}
Text(item.slot)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(VelocityTheme.foreground)
Text("Owner: \(item.owner) · \(item.location)")
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
}
.padding(16)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(VelocityTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
)
)
}
VStack(alignment: .leading, spacing: 8) {
Text("Calendar synthesis")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text(calendarSynthesis)
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(16)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color(red: 0.09, green: 0.15, blue: 0.33).opacity(0.5))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(VelocityTheme.borderAccent, lineWidth: 1)
)
)
}
.padding(22)
.frame(maxWidth: .infinity, alignment: .topLeading)
.glassCard(cornerRadius: 20)
}
private var filteredAgendaItems: [CalendarAgendaItem] {
let weekday = selectedDay.lowercased()
let filtered = agendaItems.filter { $0.slot.lowercased().contains(weekday) }
return filtered.isEmpty ? agendaItems : filtered
}
private var calendarSynthesis: String {
if agendaItems.isEmpty {
return "Velocity has not received any live calendar events yet. Once mobile-edge reminders and confirmed follow-ups are written, they will appear here automatically."
}
return "Live calendar events are being pulled from the mobile-edge backend and refreshed automatically so follow-up timing stays aligned with confirmed operator actions."
}
private var loadingPanel: some View {
VStack(alignment: .leading, spacing: 12) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
Text("Loading live calendar events...")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text("This surface reads confirmed mobile-edge calendar records for the authenticated Velocity user.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(20)
.frame(maxWidth: .infinity, alignment: .leading)
.glassCard(cornerRadius: 20)
}
private func errorBanner(_ message: String) -> some View {
Text(message)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(VelocityTheme.danger)
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(VelocityTheme.danger.opacity(0.10))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(VelocityTheme.danger.opacity(0.22), lineWidth: 1)
)
)
}
private func loadCalendar(silent: Bool = false) async {
if !silent {
isLoading = true
}
do {
let events = try await VelocityAPIClient.shared.fetchCalendarEvents()
let mapped = events.map { event in
CalendarAgendaItem(
id: event.calendarEventId,
title: event.title,
slot: formattedSlot(startAt: event.startAt),
owner: event.createdBy.replacingOccurrences(of: "_", with: " ").capitalized,
location: event.location ?? "No location",
type: event.status.capitalized,
color: color(for: event.status)
)
}
let metrics = buildMetrics(from: events)
await MainActor.run {
agendaItems = mapped
calendarMetrics = metrics
if let firstDay = mapped.first?.slot.components(separatedBy: " · ").first {
selectedDay = firstDay
}
errorMessage = nil
isLoading = false
}
} catch {
await MainActor.run {
agendaItems = []
calendarMetrics = [
CalendarQuickMetric(id: "today", label: "Today", value: "0 slots", color: VelocityTheme.accent),
CalendarQuickMetric(id: "priority", label: "Confirmed", value: "0", color: VelocityTheme.success),
CalendarQuickMetric(id: "pending", label: "Pending invites", value: "0", color: VelocityTheme.warning),
]
errorMessage = error.localizedDescription
isLoading = false
}
}
}
private func buildMetrics(from events: [VelocityCalendarEventDTO]) -> [CalendarQuickMetric] {
let today = events.filter { isToday($0.startAt) }.count
let confirmed = events.filter { $0.status.lowercased() == "confirmed" }.count
let tentative = events.filter { $0.status.lowercased() == "tentative" }.count
return [
CalendarQuickMetric(id: "today", label: "Today", value: "\(today) slots", color: VelocityTheme.accent),
CalendarQuickMetric(id: "priority", label: "Confirmed", value: "\(confirmed)", color: VelocityTheme.success),
CalendarQuickMetric(id: "pending", label: "Pending invites", value: "\(tentative)", color: VelocityTheme.warning),
]
}
private func daySubtitle(_ day: String) -> String {
let count = agendaItems.filter { $0.slot.lowercased().contains(day.lowercased()) }.count
return count == 1 ? "1 scheduled item" : "\(count) scheduled items"
}
private func formattedSlot(startAt: String) -> String {
guard let date = ISO8601DateFormatter().date(from: startAt) else {
return startAt
}
let dayFormatter = DateFormatter()
dayFormatter.dateFormat = "EEEE"
let timeFormatter = DateFormatter()
timeFormatter.dateFormat = "h:mm a"
return "\(dayFormatter.string(from: date)) · \(timeFormatter.string(from: date))"
}
private func isToday(_ startAt: String) -> Bool {
guard let date = ISO8601DateFormatter().date(from: startAt) else {
return false
}
return Calendar.current.isDateInToday(date)
}
private func color(for status: String) -> Color {
switch status.lowercased() {
case "confirmed":
return VelocityTheme.success
case "tentative":
return VelocityTheme.warning
default:
return VelocityTheme.mutedFg
}
}
}
#Preview {
CalendarView()
}

View File

@@ -1,442 +0,0 @@
import SwiftUI
struct DashboardView: View {
private var store: AppStore { AppStore.shared }
@State private var chatInput = ""
private let columns = [GridItem(.adaptive(minimum: 220), spacing: 14)]
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
pageHeader
// KPI Grid live from store
LazyVGrid(columns: columns, spacing: 14) {
LiveKPICard(
title: "Visitors",
value: "\(store.metrics.activeVisitors)",
subtitle: "Active now",
icon: "person.2",
accentColor: VelocityTheme.accent,
glowColor: VelocityTheme.accent.opacity(0.22),
badge: "LIVE"
)
LiveKPICard(
title: "Revenue",
value: store.metrics.revenue,
subtitle: "30-day forecast",
icon: "chart.line.uptrend.xyaxis",
accentColor: Color(red: 0.13, green: 0.83, blue: 0.93),
glowColor: Color(red: 0.13, green: 0.83, blue: 0.93).opacity(0.18)
)
LiveKPICard(
title: "AI Jobs",
value: "\(store.metrics.aiJobs)",
subtitle: "Queue depth",
icon: "cpu",
accentColor: Color(red: 0.60, green: 0.57, blue: 0.99),
glowColor: Color(red: 0.60, green: 0.57, blue: 0.99).opacity(0.20)
)
LiveKPICard(
title: "Listings",
value: "\(store.metrics.dailyVisitors)",
subtitle: "Active units",
icon: "building.2",
accentColor: VelocityTheme.success,
glowColor: VelocityTheme.success.opacity(0.18)
)
}
.animation(.easeInOut(duration: 0.4), value: store.metrics.activeVisitors)
// Sentiment Gauge
sentimentGauge
// System Health
systemHealthPanel
// AI Chat Widget
aiChatWidget
}
.padding(20)
}
.background(VelocityTheme.background)
.scrollContentBackground(.hidden)
}
// MARK: Page Header
private var pageHeader: some View {
HStack {
VStack(alignment: .leading, spacing: 3) {
Text("Dashboard")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text("Project Velocity · v.1.1")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
HStack(spacing: 5) {
Circle()
.fill(VelocityTheme.success)
.frame(width: 7, height: 7)
Text("Live")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(VelocityTheme.success)
}
}
}
// MARK: Sentiment Gauge
private var sentimentGauge: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "waveform.path.ecg")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.accent)
Text("Sentiment Thermometer")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Spacer()
Text("Showroom Vibe")
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.mutedFg)
let label = store.metrics.sentimentScore >= 70 ? "Excellent" :
store.metrics.sentimentScore >= 50 ? "Good" : "Needs Attention"
let labelColor: Color = store.metrics.sentimentScore >= 70 ? VelocityTheme.success :
store.metrics.sentimentScore >= 50 ? VelocityTheme.warning : VelocityTheme.danger
Text(label)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(labelColor)
}
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 5)
.fill(Color.white.opacity(0.05))
.frame(height: 26)
RoundedRectangle(cornerRadius: 5)
.fill(
LinearGradient(
colors: [Color(red: 0.11, green: 0.30, blue: 0.86),
VelocityTheme.accent,
Color(red: 0.38, green: 0.65, blue: 0.98)],
startPoint: .leading, endPoint: .trailing
)
)
.frame(width: geo.size.width * (store.metrics.sentimentScore / 100), height: 26)
.shadow(color: VelocityTheme.accent.opacity(0.6), radius: 6)
.animation(.easeInOut(duration: 0.8), value: store.metrics.sentimentScore)
Text("\(Int(store.metrics.sentimentScore))%")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
}
}
.frame(height: 26)
}
.padding(20)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
.overlay(RoundedRectangle(cornerRadius: 16).stroke(VelocityTheme.borderAccent, lineWidth: 1))
)
}
// MARK: System Health
private var systemHealthPanel: some View {
let gauges: [(label: String, value: Double, color: Color)] = [
("CPU", store.metrics.systemHealth.cpu, VelocityTheme.accent),
("GPU", store.metrics.systemHealth.gpu, Color(red: 0.50, green: 0.56, blue: 0.97)),
("Memory", store.metrics.systemHealth.memory, Color(red: 0.13, green: 0.83, blue: 0.93)),
]
return VStack(alignment: .leading, spacing: 14) {
HStack {
Image(systemName: "cpu").font(.system(size: 12)).foregroundStyle(VelocityTheme.accent)
Text("System Health")
.font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
Spacer()
HStack(spacing: 4) {
Circle().fill(VelocityTheme.success).frame(width: 5, height: 5)
Text("Optimal").font(.system(size: 11, weight: .medium)).foregroundStyle(VelocityTheme.success)
}
}
HStack(spacing: 16) {
ForEach(gauges, id: \.label) { g in
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(g.label).font(.system(size: 10, weight: .medium)).tracking(0.8)
.foregroundStyle(VelocityTheme.mutedFg)
Spacer()
Text("\(Int(g.value * 100))%").font(.system(size: 10, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
}
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.05)).frame(height: 5)
RoundedRectangle(cornerRadius: 3).fill(g.color)
.frame(width: geo.size.width * g.value, height: 5)
.shadow(color: g.color.opacity(0.6), radius: 4)
.animation(.easeInOut(duration: 0.6), value: g.value)
}
}
.frame(height: 5)
}
.frame(maxWidth: .infinity)
}
}
}
.padding(20)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
.overlay(RoundedRectangle(cornerRadius: 16).stroke(VelocityTheme.borderAccent, lineWidth: 1))
)
}
// MARK: AI Chat Widget
private var aiChatWidget: some View {
VStack(spacing: 0) {
// Header
HStack(spacing: 10) {
ZStack {
Circle().fill(VelocityTheme.accent.opacity(0.18)).frame(width: 34, height: 34)
Image(systemName: "sparkles").font(.system(size: 14)).foregroundStyle(VelocityTheme.accent)
}
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 5) {
Text("AI Onboard").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
Image(systemName: "staroflife.fill").font(.system(size: 7)).foregroundStyle(VelocityTheme.accent)
}
HStack(spacing: 4) {
Circle().fill(VelocityTheme.success).frame(width: 5, height: 5)
Text("Online").font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg)
}
}
Spacer()
}
.padding(16)
Divider().background(VelocityTheme.borderSubtle)
// Messages
ScrollViewReader { proxy in
ScrollView {
VStack(spacing: 12) {
ForEach(store.dashboardMessages) { msg in
ChatBubble(message: msg)
.id(msg.id)
}
if store.isDashboardThinking {
TypingIndicator()
}
}
.padding(16)
}
.frame(height: 240)
.onChange(of: store.dashboardMessages.count) {
if let last = store.dashboardMessages.last {
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
}
}
.onChange(of: store.isDashboardThinking) {
if store.isDashboardThinking {
withAnimation { proxy.scrollTo("typing", anchor: .bottom) }
}
}
}
Divider().background(VelocityTheme.borderSubtle)
// Input
HStack(spacing: 10) {
TextField("Ask AI assistant...", text: $chatInput)
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.foreground)
.tint(VelocityTheme.accent)
.onSubmit { sendDashboardMessage() }
Button(action: sendDashboardMessage) {
Image(systemName: "arrow.up.circle.fill")
.font(.system(size: 22))
.foregroundStyle(chatInput.isEmpty ? VelocityTheme.mutedFg : VelocityTheme.accent)
}
.disabled(chatInput.isEmpty || store.isDashboardThinking)
}
.padding(14)
}
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
.overlay(RoundedRectangle(cornerRadius: 16).stroke(VelocityTheme.borderAccent, lineWidth: 1))
)
}
private func sendDashboardMessage() {
let text = chatInput.trimmingCharacters(in: .whitespaces)
guard !text.isEmpty else { return }
chatInput = ""
store.addDashboardMessage(sender: "user", content: text)
store.isDashboardThinking = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
store.isDashboardThinking = false
store.addDashboardMessage(
sender: "ai",
content: dashboardAIResponse(for: text)
)
}
}
private func dashboardAIResponse(for prompt: String) -> String {
let p = prompt.lowercased()
if p.contains("penthouse") || p.contains("apex") {
return "Apex Innovations probability score is now at 85%. I recommend scheduling a closing meeting next Tuesday — sentiment analysis shows high engagement."
} else if p.contains("visitor") || p.contains("traffic") {
return "Currently \(store.metrics.activeVisitors) active visitors. Zone B (Penthouse Gallery) is generating the highest dwell time today — average 8 minutes."
} else if p.contains("revenue") || p.contains("deal") {
return "Pipeline value stands at \(store.metrics.revenue) for the 30-day forecast. 3 deals are in final negotiation — I recommend prioritising Mohammed Al-Rashid."
} else if p.contains("sentiment") {
return "Showroom sentiment is at \(Int(store.metrics.sentimentScore))% — above the excellent threshold. Visitors in Zone D (Reception) show the highest satisfaction scores."
}
return "I've analysed the current data. Revenue is tracking at \(store.metrics.revenue) with \(store.metrics.activeVisitors) active visitors. Shall I prepare a detailed pipeline report?"
}
}
// MARK: KPI Card (live-bound)
private struct LiveKPICard: View {
let title: String
let value: String
let subtitle: String
let icon: String
let accentColor: Color
let glowColor: Color
var badge: String? = nil
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack {
ZStack {
RoundedRectangle(cornerRadius: 8).fill(accentColor.opacity(0.12)).frame(width: 32, height: 32)
Image(systemName: icon).font(.system(size: 14, weight: .medium)).foregroundStyle(accentColor)
}
Spacer()
if let badge {
HStack(spacing: 4) {
Circle().fill(VelocityTheme.success).frame(width: 5, height: 5)
Text(badge).font(.system(size: 9, weight: .semibold)).foregroundStyle(VelocityTheme.success)
}
}
}
.padding(.bottom, 20)
Text(title.uppercased())
.font(.system(size: 10, weight: .medium)).tracking(1.2)
.foregroundStyle(VelocityTheme.mutedFg).padding(.bottom, 4)
Text(value)
.font(.system(size: 34, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
.contentTransition(.numericText())
.minimumScaleFactor(0.7).lineLimit(1).padding(.bottom, 4)
Text(subtitle).font(.system(size: 11)).foregroundStyle(VelocityTheme.subtleFg)
}
.padding(20)
.frame(maxWidth: .infinity, minHeight: 148, alignment: .leading)
.background(
ZStack(alignment: .bottomTrailing) {
RoundedRectangle(cornerRadius: 16).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
Ellipse().fill(glowColor).frame(width: 120, height: 90).blur(radius: 28).offset(x: 20, y: 20)
VStack {
Rectangle()
.fill(LinearGradient(colors: [.clear, .white.opacity(0.10), .clear], startPoint: .leading, endPoint: .trailing))
.frame(height: 1)
Spacer()
}
.clipShape(RoundedRectangle(cornerRadius: 16))
}
.overlay(RoundedRectangle(cornerRadius: 16).stroke(VelocityTheme.borderAccent, lineWidth: 1))
.shadow(color: .black.opacity(0.55), radius: 16, y: 4)
)
.clipShape(RoundedRectangle(cornerRadius: 16))
}
}
// MARK: Chat Bubble
private struct ChatBubble: View {
let message: ChatMessage
private var isUser: Bool { message.sender == "user" }
var body: some View {
HStack(alignment: .bottom, spacing: 8) {
if isUser { Spacer(minLength: 40) }
if !isUser {
ZStack {
Circle().fill(VelocityTheme.accent.opacity(0.18)).frame(width: 26, height: 26)
Image(systemName: "sparkles").font(.system(size: 10)).foregroundStyle(VelocityTheme.accent)
}
}
Text(message.content)
.font(.system(size: 13))
.foregroundStyle(isUser ? .white : VelocityTheme.foreground)
.padding(.horizontal, 12).padding(.vertical, 9)
.background(
RoundedRectangle(cornerRadius: isUser ? 14 : 14)
.fill(isUser
? VelocityTheme.accent.opacity(0.85)
: Color.white.opacity(0.06))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(isUser ? .clear : Color.white.opacity(0.10), lineWidth: 1)
)
)
if isUser {
ZStack {
Circle().fill(Color(red: 0.08, green: 0.10, blue: 0.18)).frame(width: 26, height: 26)
Text("A").font(.system(size: 10, weight: .bold)).foregroundStyle(VelocityTheme.foreground)
}
}
if !isUser { Spacer(minLength: 40) }
}
}
}
// MARK: Typing Indicator
private struct TypingIndicator: View {
@State private var phase = 0
var body: some View {
HStack(alignment: .bottom, spacing: 8) {
ZStack {
Circle().fill(VelocityTheme.accent.opacity(0.18)).frame(width: 26, height: 26)
Image(systemName: "sparkles").font(.system(size: 10)).foregroundStyle(VelocityTheme.accent)
}
HStack(spacing: 4) {
ForEach(0..<3, id: \.self) { i in
Circle()
.fill(VelocityTheme.mutedFg)
.frame(width: 6, height: 6)
.scaleEffect(phase == i ? 1.4 : 0.8)
.animation(.easeInOut(duration: 0.4).repeatForever().delay(Double(i) * 0.15), value: phase)
}
}
.padding(.horizontal, 14).padding(.vertical, 12)
.background(RoundedRectangle(cornerRadius: 14).fill(Color.white.opacity(0.06))
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.white.opacity(0.10), lineWidth: 1)))
Spacer(minLength: 40)
}
.id("typing")
.onAppear {
withAnimation { phase = 1 }
Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in
phase = (phase + 1) % 3
}
}
}
}

View File

@@ -1,118 +0,0 @@
import ARKit
import CoreLocation
import CoreMotion
import SceneKit
import SwiftUI
struct ARSunOverlayView: UIViewRepresentable {
@Binding var sunNodesReady: Bool
func makeCoordinator() -> Coordinator {
Coordinator(sunNodesReady: $sunNodesReady)
}
func makeUIView(context: Context) -> ARSCNView {
let view = ARSCNView(frame: .zero)
view.delegate = context.coordinator
view.scene = SCNScene()
view.automaticallyUpdatesLighting = true
let config = ARWorldTrackingConfiguration()
config.worldAlignment = .gravityAndHeading
view.session.run(config)
context.coordinator.attach(to: view)
return view
}
func updateUIView(_ uiView: ARSCNView, context: Context) {}
static func dismantleUIView(_ uiView: ARSCNView, coordinator: Coordinator) {
uiView.session.pause()
coordinator.stop()
}
final class Coordinator: NSObject, ARSCNViewDelegate, CLLocationManagerDelegate {
private let locationManager = CLLocationManager()
private let motionManager = CMMotionManager()
private weak var sceneView: ARSCNView?
private var heading: CLLocationDirection = 0
private var coordinate: CLLocationCoordinate2D?
@Binding private var sunNodesReady: Bool
init(sunNodesReady: Binding<Bool>) {
_sunNodesReady = sunNodesReady
super.init()
locationManager.delegate = self
locationManager.desiredAccuracy = kCLLocationAccuracyBest
locationManager.headingFilter = 1
locationManager.requestWhenInUseAuthorization()
locationManager.startUpdatingLocation()
locationManager.startUpdatingHeading()
startMotion()
}
func attach(to sceneView: ARSCNView) {
self.sceneView = sceneView
addSunPathNodesIfPossible()
}
func stop() {
motionManager.stopDeviceMotionUpdates()
locationManager.stopUpdatingHeading()
locationManager.stopUpdatingLocation()
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard coordinate == nil, let location = locations.last else { return }
coordinate = location.coordinate
addSunPathNodesIfPossible()
}
func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) {
heading = newHeading.trueHeading > 0 ? newHeading.trueHeading : newHeading.magneticHeading
addSunPathNodesIfPossible()
}
private func startMotion() {
guard motionManager.isDeviceMotionAvailable else { return }
motionManager.deviceMotionUpdateInterval = 0.1
motionManager.startDeviceMotionUpdates()
}
private func addSunPathNodesIfPossible() {
guard
let sceneView,
let coordinate,
!sunNodesReady
else { return }
let samples = SunMath.sunPathSamples(for: Date(), coordinate: coordinate)
let sorted = samples.sorted { $0.key < $1.key }
let root = SCNNode()
let northOffset = (heading).radians
let radius: Float = 1.8
for (_, pos) in sorted {
let elevation = Float(pos.elevation.radians)
let azimuth = Float((pos.azimuth).radians) - Float(northOffset)
let x = radius * cos(elevation) * sin(azimuth)
let y = radius * sin(elevation)
let z = -radius * cos(elevation) * cos(azimuth)
let sphere = SCNSphere(radius: 0.03)
sphere.firstMaterial?.diffuse.contents = UIColor.systemYellow
let node = SCNNode(geometry: sphere)
node.position = SCNVector3(x, y, z)
root.addChildNode(node)
}
sceneView.scene.rootNode.addChildNode(root)
sunNodesReady = true
}
}
}
private extension Double {
var radians: Double { self * .pi / 180.0 }
}

View File

@@ -1,439 +0,0 @@
import AVFoundation
import Observation
import SceneKit
import SwiftUI
import UIKit
@Observable
final class InventoryStore {
enum Mode: String, CaseIterable, Identifiable {
case sunseeker = "Sunseeker"
case dreamWeaver = "Dream Weaver"
case dollhouse = "Dollhouse"
var id: String { rawValue }
}
var mode: Mode = .sunseeker
var selectedPrompt: String = "Modern Islamic"
var sourceImage: UIImage?
var generatedImage: UIImage?
var isProcessing: Bool = false
var sunNodesReady: Bool = false
var dollhouseHour: Double = 12
let prompts = ["Modern Islamic", "Minimalist", "Night Mode"]
}
struct InventoryView: View {
@State private var store = InventoryStore()
@State private var showCamera = false
@State private var sliderTickHour = 12
private let haptics = UIImpactFeedbackGenerator(style: .light)
var body: some View {
VStack(alignment: .leading, spacing: 16) {
// Page header
VStack(alignment: .leading, spacing: 4) {
Text("Inventory")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text("Sunseeker · Dream Weaver · Dollhouse")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(.horizontal, 20)
.padding(.top, 20)
Picker("Mode", selection: $store.mode) {
ForEach(InventoryStore.Mode.allCases) { mode in
Text(mode.rawValue).tag(mode)
}
}
.pickerStyle(.segmented)
.padding(.horizontal, 20)
.padding(.top, 12)
Group {
switch store.mode {
case .sunseeker:
#if targetEnvironment(simulator)
ZStack {
VStack(spacing: 14) {
Image(systemName: "camera.metering.unknown")
.font(.system(size: 40))
.foregroundStyle(VelocityTheme.mutedFg)
Text("AR Not Available in Simulator")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text("Sunseeker requires a real device with a camera and compass. Run on iPhone or iPad to use this feature.")
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.mutedFg)
.multilineTextAlignment(.center)
}
.padding(24)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(VelocityTheme.borderAccent, lineWidth: 1)
)
)
#else
SunseekerPanel(sunNodesReady: $store.sunNodesReady)
#endif
case .dreamWeaver:
DreamWeaverPanel(
sourceImage: $store.sourceImage,
generatedImage: $store.generatedImage,
selectedPrompt: $store.selectedPrompt,
isProcessing: $store.isProcessing,
prompts: store.prompts,
showCamera: $showCamera
)
case .dollhouse:
DollhousePanel(hour: $store.dollhouseHour, tickHour: $sliderTickHour, haptics: haptics)
}
}
.padding(.horizontal, 20)
.padding(.bottom, 20)
.animation(.easeInOut(duration: 0.25), value: store.mode)
}
.background(VelocityTheme.background)
.onAppear {
// Dark-theme the segmented control
UISegmentedControl.appearance().selectedSegmentTintColor = UIColor(
red: 0.231, green: 0.510, blue: 0.965, alpha: 0.85)
UISegmentedControl.appearance().setTitleTextAttributes(
[.foregroundColor: UIColor.white], for: .selected)
UISegmentedControl.appearance().setTitleTextAttributes(
[.foregroundColor: UIColor(white: 0.62, alpha: 1)], for: .normal)
UISegmentedControl.appearance().backgroundColor = UIColor(
red: 0.031, green: 0.039, blue: 0.071, alpha: 1)
}
.sheet(isPresented: $showCamera) {
CameraPicker(image: $store.sourceImage, isPresented: $showCamera)
}
}
}
private struct SunseekerPanel: View {
@Binding var sunNodesReady: Bool
var body: some View {
ZStack(alignment: .topLeading) {
ARSunOverlayView(sunNodesReady: $sunNodesReady)
.clipShape(RoundedRectangle(cornerRadius: 20))
DashedSunLine()
.stroke(Color.yellow.opacity(0.9), style: StrokeStyle(lineWidth: 3, dash: [10, 8]))
.padding(.horizontal, 24)
.padding(.vertical, 80)
VStack(alignment: .leading, spacing: 8) {
Text("Sunseeker")
.font(.headline)
Text("Point the iPad toward windows to inspect yearly sun-entry path.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(14)
.background {
GlassBlurView(style: .systemThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.padding(20)
}
}
}
private struct DreamWeaverPanel: View {
@Binding var sourceImage: UIImage?
@Binding var generatedImage: UIImage?
@Binding var selectedPrompt: String
@Binding var isProcessing: Bool
let prompts: [String]
@Binding var showCamera: Bool
var body: some View {
VStack(spacing: 14) {
ZStack {
RoundedRectangle(cornerRadius: 20)
.fill(Color.black.opacity(0.9))
if let sourceImage {
Image(uiImage: sourceImage)
.resizable()
.scaledToFit()
.clipShape(RoundedRectangle(cornerRadius: 16))
.padding(12)
} else {
ContentUnavailableView("No Capture", systemImage: "camera.viewfinder", description: Text("Tap Capture to snap a room."))
.foregroundStyle(.white)
}
if let generatedImage {
Image(uiImage: generatedImage)
.resizable()
.scaledToFit()
.clipShape(RoundedRectangle(cornerRadius: 16))
.padding(12)
.transition(.opacity)
}
if isProcessing {
ProcessingOverlay()
}
}
.frame(maxWidth: .infinity, minHeight: 420)
.animation(.easeInOut(duration: 0.35), value: generatedImage)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(prompts, id: \.self) { prompt in
Text(prompt)
.font(.subheadline.weight(.semibold))
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(prompt == selectedPrompt ? Color.yellow.opacity(0.85) : Color.white.opacity(0.12))
)
.onTapGesture { selectedPrompt = prompt }
}
}
}
HStack(spacing: 12) {
Button("Capture") {
showCamera = true
}
.buttonStyle(.borderedProminent)
Button("Reimagine") {
Task { await generate() }
}
.buttonStyle(.bordered)
.disabled(sourceImage == nil || isProcessing)
}
}
.padding(16)
.background {
GlassBlurView(style: .systemThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 18))
}
}
@MainActor
private func generate() async {
guard let sourceImage, !isProcessing else { return }
isProcessing = true
do {
let result = try await ComfyClient.shared.generateImage(source: sourceImage, prompt: selectedPrompt)
withAnimation(.easeInOut(duration: 0.4)) {
generatedImage = result
}
} catch {
print("Dream Weaver error: \(error)")
}
isProcessing = false
}
}
private struct DollhousePanel: View {
@Binding var hour: Double
@Binding var tickHour: Int
let haptics: UIImpactFeedbackGenerator
var body: some View {
VStack(spacing: 12) {
SceneKitDollhouseView(hour: $hour)
.clipShape(RoundedRectangle(cornerRadius: 20))
.frame(maxWidth: .infinity, minHeight: 460)
VStack(alignment: .leading, spacing: 8) {
Text(String(format: "Time: %02d:00", Int(hour.rounded())))
.font(.headline)
Slider(value: $hour, in: 0...24, step: 0.25)
.onChange(of: hour) { _, newValue in
let rounded = Int(newValue.rounded())
if rounded != tickHour {
tickHour = rounded
haptics.impactOccurred(intensity: 0.7)
}
}
}
.padding(14)
.background {
GlassBlurView(style: .systemThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
}
}
}
private struct SceneKitDollhouseView: UIViewRepresentable {
@Binding var hour: Double
func makeCoordinator() -> Coordinator {
Coordinator()
}
func makeUIView(context: Context) -> SCNView {
let view = SCNView()
view.scene = context.coordinator.scene
view.autoenablesDefaultLighting = false
view.allowsCameraControl = true
view.backgroundColor = UIColor.systemBackground
context.coordinator.setupScene()
context.coordinator.updateSunLight(hour: hour)
return view
}
func updateUIView(_ uiView: SCNView, context: Context) {
context.coordinator.updateSunLight(hour: hour)
}
final class Coordinator {
let scene = SCNScene()
private let sunNode = SCNNode()
func setupScene() {
if let modelScene = SCNScene(named: "Building.usdz") ?? SCNScene(named: "Building.scn") {
let container = SCNNode()
for child in modelScene.rootNode.childNodes {
container.addChildNode(child.clone())
}
scene.rootNode.addChildNode(container)
} else {
let fallback = SCNFloor()
fallback.firstMaterial?.diffuse.contents = UIColor.secondarySystemBackground
scene.rootNode.addChildNode(SCNNode(geometry: fallback))
}
let camera = SCNCamera()
let cameraNode = SCNNode()
cameraNode.camera = camera
cameraNode.position = SCNVector3(0, 4, 10)
scene.rootNode.addChildNode(cameraNode)
let light = SCNLight()
light.type = .directional
light.intensity = 1_200
light.castsShadow = true
sunNode.light = light
scene.rootNode.addChildNode(sunNode)
let ambient = SCNLight()
ambient.type = .ambient
ambient.intensity = 200
let ambientNode = SCNNode()
ambientNode.light = ambient
scene.rootNode.addChildNode(ambientNode)
}
func updateSunLight(hour: Double) {
let normalized = (hour / 24.0) * (2 * Double.pi)
let x = Float(cos(normalized) * 8.0)
let y = Float(max(sin(normalized) * 8.0, 1.0))
let z = Float(sin(normalized + .pi / 3) * 6.0)
sunNode.position = SCNVector3(x, y, z)
sunNode.look(at: SCNVector3(0, 0, 0))
}
}
}
private struct ProcessingOverlay: View {
@State private var animate = false
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 16)
.fill(.black.opacity(0.45))
Text("AI Processing...")
.font(.headline.weight(.bold))
.foregroundStyle(.white)
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background {
GlassBlurView(style: .systemUltraThinMaterialDark)
.clipShape(Capsule())
}
.overlay(
Rectangle()
.fill(
LinearGradient(
colors: [.clear, .white.opacity(0.6), .clear],
startPoint: .leading,
endPoint: .trailing
)
)
.rotationEffect(.degrees(18))
.offset(x: animate ? 160 : -160)
.animation(.linear(duration: 1.2).repeatForever(autoreverses: false), value: animate)
.blendMode(.screen)
.mask(Capsule().frame(height: 44))
)
}
.padding(12)
.onAppear { animate = true }
}
}
private struct DashedSunLine: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.minX, y: rect.maxY * 0.75))
path.addQuadCurve(
to: CGPoint(x: rect.maxX, y: rect.maxY * 0.25),
control: CGPoint(x: rect.midX, y: rect.minY + 30)
)
return path
}
}
private struct CameraPicker: UIViewControllerRepresentable {
@Binding var image: UIImage?
@Binding var isPresented: Bool
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.delegate = context.coordinator
if UIImagePickerController.isSourceTypeAvailable(.camera) {
picker.sourceType = .camera
picker.cameraCaptureMode = .photo
} else {
picker.sourceType = .photoLibrary
}
return picker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
final class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
private let parent: CameraPicker
init(_ parent: CameraPicker) {
self.parent = parent
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
parent.isPresented = false
}
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
if let captured = info[.originalImage] as? UIImage {
parent.image = captured
}
parent.isPresented = false
}
}
}

View File

@@ -1,960 +0,0 @@
import SwiftUI
// MARK: Oracle Canvas Modes
enum OracleMode: String, CaseIterable {
case pipeline = "Pipeline"
case teamPerformance = "Team Performance"
case accountTimeline = "Account Timeline"
case leadMap = "Lead Map"
case calendarTasks = "Calendar & Tasks"
var icon: String {
switch self {
case .pipeline: return "square.grid.3x1.below.line.grid.1x2"
case .teamPerformance: return "person.3"
case .accountTimeline: return "clock.arrow.circlepath"
case .leadMap: return "map"
case .calendarTasks: return "calendar"
}
}
}
// MARK: Pipeline mock data (extended with detail fields)
struct OracleLeadCard: Identifiable {
let id = UUID()
let initials: String
let name: String
let company: String
let value: String
let status: LeadStatus
let phone: String
let interest: String
let qualification: String
}
private let pipelineData: [(stage: String, cards: [OracleLeadCard])] = [
("New", [
OracleLeadCard(initials: "JW", name: "James Wilson", company: "Website", value: "AED 3.5M", status: .new,
phone: "+971 52 456 7890", interest: "1BR Investment", qualification: "Potential"),
]),
("Qualified", [
OracleLeadCard(initials: "FH", name: "Fatima Hassan", company: "WhatsApp", value: "AED 12M", status: .qualified,
phone: "+971 54 321 0987", interest: "3BR + Maid", qualification: "Whale"),
OracleLeadCard(initials: "SC", name: "Sarah Chen", company: "Walk-in", value: "AED 6.5M", status: .engaged,
phone: "+971 50 987 6543", interest: "2BR Sea View", qualification: "Potential"),
]),
("Proposal", [
OracleLeadCard(initials: "MA", name: "Mohammed Al-Rashid", company: "WhatsApp", value: "AED 15M+", status: .hot,
phone: "+971 55 123 4567", interest: "Penthouse Suite", qualification: "Whale"),
]),
("Closed", [
OracleLeadCard(initials: "DK", name: "David Kumar", company: "Walk-in", value: "AED 20M", status: .closed,
phone: "+971 56 789 0123", interest: "Full Floor", qualification: "Whale"),
]),
]
struct TeamMemberData: Identifiable {
let id = UUID()
let initials: String; let name: String; let deals: Int; let revenue: String; let trend: String
}
private let teamData: [TeamMemberData] = [
.init(initials: "RA", name: "Rania Al-Farsi", deals: 42, revenue: "$2.1M", trend: "↑ 18%"),
.init(initials: "KM", name: "Khaled Mensah", deals: 31, revenue: "$1.6M", trend: "↑ 12%"),
.init(initials: "LT", name: "Lina Torres", deals: 28, revenue: "$1.3M", trend: "→ 2%"),
.init(initials: "AH", name: "Ahmed Hassan", deals: 19, revenue: "$0.9M", trend: "↓ 5%"),
]
struct OracleTimelineEvent: Identifiable {
let id = UUID()
let badge: String; let summary: String; let when: String; let detail: String
}
private let timelineEvents: [OracleTimelineEvent] = [
.init(badge: "MEETING", summary: "VR Amenity Tour Apex Innovations", when: "2h ago",
detail: "CFO and Legal Director attended. Strong reaction to the panoramic sea view suite. Follow-up proposal requested by Tuesday."),
.init(badge: "EMAIL", summary: "Proposal deck sent to legal team", when: "Yesterday",
detail: "58-page proposal + payment plan schedule sent via DocuSign. Legal team has 5 business days to review."),
.init(badge: "CALL", summary: "Budget discussion CFO confirmed", when: "Mon",
detail: "Budget ceiling confirmed at AED 15M+. CFO expressed strong preference for a penthouse unit with private terrace."),
.init(badge: "VISIT", summary: "Site walkthrough Penthouse Suite", when: "Last week",
detail: "First in-person visit. 45-minute walkthrough of Penthouse A & B. Visitor dwell time analysis showed 'excited' sentiment for 90% of the visit."),
]
struct RegionPin: Identifiable {
let id = UUID()
let label: String; let country: String; let count: Int; let temp: String; let topLead: String
}
private let mapPins: [RegionPin] = [
.init(label: "UAE", country: "🇦🇪", count: 8, temp: "hot", topLead: "Mohammed Al-Rashid"),
.init(label: "Saudi Arabia", country: "🇸🇦", count: 5, temp: "warm", topLead: "Al-Mansour Group"),
.init(label: "UK", country: "🇬🇧", count: 3, temp: "cold", topLead: "Rexford Capital"),
.init(label: "USA", country: "🇺🇸", count: 4, temp: "warm", topLead: "Apex Innovations"),
.init(label: "India", country: "🇮🇳", count: 6, temp: "hot", topLead: "Starlight Systems"),
.init(label: "Germany", country: "🇩🇪", count: 2, temp: "cold", topLead: "TechWave GmbH"),
]
struct CalTask: Identifiable {
let id = UUID()
let title: String; let subtitle: String; let due: String
}
private let calTasks: [CalTask] = [
.init(title: "Follow up with Mohammed", subtitle: "High-value penthouse lead 2 unread messages", due: "Today 3 PM"),
.init(title: "Send contract to Fatima", subtitle: "3BR unit finalised payment plan to confirm", due: "Tomorrow 10 AM"),
.init(title: "Schedule VR tour James", subtitle: "Website lead, potential 1BR investor", due: "Thu 2 PM"),
]
// MARK: OracleView (main)
struct OracleView: View {
@State private var selectedMode: OracleMode = .pipeline
@State private var prompt = "Show me a pipeline view by stage for Q4."
@State private var insightText = "Pipeline is healthy. Mohammed Al-Rashid is your highest-value close opportunity — follow up within 24 hours."
@State private var isSubmitting = false
// Sheet states
@State private var selectedLead: OracleLeadCard? = nil
@State private var selectedMember: TeamMemberData? = nil
@State private var selectedRegion: RegionPin? = nil
@State private var scheduledTask: CalTask? = nil
@State private var showScheduleConfirm = false
var body: some View {
ZStack(alignment: .bottom) {
VStack(alignment: .leading, spacing: 0) {
pageHeader
.padding(.horizontal, 24).padding(.top, 24).padding(.bottom, 16)
insightCard
.padding(.horizontal, 24).padding(.bottom, 14)
ScrollView {
canvasView
.padding(.horizontal, 24)
.padding(.bottom, 120)
}
}
promptBar
.padding(.horizontal, 20)
.padding(.bottom, 12)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(VelocityTheme.background)
// Lead detail sheet
.sheet(item: $selectedLead) { card in
LeadDetailSheet(card: card)
}
// Team member sheet
.sheet(item: $selectedMember) { member in
MemberDetailSheet(member: member)
}
// Region callout sheet
.sheet(item: $selectedRegion) { pin in
RegionDetailSheet(pin: pin)
}
// Schedule confirmation alert
.alert("Confirm Schedule",
isPresented: $showScheduleConfirm,
presenting: scheduledTask) { task in
Button("Schedule") {
// In a real app this would create a calendar event
}
Button("Cancel", role: .cancel) {}
} message: { task in
Text("Add \"\(task.title)\" to your calendar for \(task.due)?")
}
}
// MARK: Sub-views
private var pageHeader: some View {
HStack {
VStack(alignment: .leading, spacing: 3) {
Text("Oracle").font(.system(size: 28, weight: .bold)).foregroundStyle(VelocityTheme.foreground)
Text("AI intelligence pipeline").font(.system(size: 12)).foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
if isSubmitting {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
.scaleEffect(0.8)
}
}
}
private var insightCard: some View {
HStack(alignment: .center, spacing: 0) {
RoundedRectangle(cornerRadius: 2)
.fill(LinearGradient(colors: [Color(red: 0.58, green: 0.77, blue: 0.99), VelocityTheme.accent],
startPoint: .top, endPoint: .bottom))
.frame(width: 3)
HStack {
VStack(alignment: .leading, spacing: 3) {
Text("AI INSIGHT").font(.system(size: 9, weight: .semibold)).tracking(1.5)
.foregroundStyle(VelocityTheme.accent)
Text(insightText).font(.system(size: 13)).foregroundStyle(VelocityTheme.foreground).lineLimit(2)
}
Spacer()
HStack(spacing: 5) {
Image(systemName: selectedMode.icon).font(.system(size: 11)).foregroundStyle(VelocityTheme.accent)
Text(selectedMode.rawValue).font(.system(size: 11, weight: .medium))
.foregroundStyle(Color(red: 0.58, green: 0.77, blue: 0.99))
}
}
.padding(.horizontal, 14).padding(.vertical, 12)
}
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color(red: 0.09, green: 0.15, blue: 0.33).opacity(0.55))
.overlay(RoundedRectangle(cornerRadius: 12).stroke(VelocityTheme.borderAccent, lineWidth: 1))
)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
@ViewBuilder
private var canvasView: some View {
switch selectedMode {
case .pipeline:
PipelineCanvas(onSelectLead: { selectedLead = $0 })
case .teamPerformance:
TeamPerformanceCanvas(onSelectMember: { selectedMember = $0 })
case .accountTimeline:
AccountTimelineCanvas()
case .leadMap:
LeadMapCanvas(onSelectRegion: { selectedRegion = $0 })
case .calendarTasks:
CalendarCanvas(onSchedule: { task in
scheduledTask = task
showScheduleConfirm = true
})
}
}
// MARK: Prompt Bar
private var promptBar: some View {
VStack(spacing: 0) {
TextField("Ask Oracle anything…", text: $prompt)
.font(.system(size: 14))
.foregroundStyle(VelocityTheme.foreground)
.tint(VelocityTheme.accent)
.onSubmit { submitPrompt() }
.padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 8)
HStack {
Menu {
ForEach(OracleMode.allCases, id: \.self) { mode in
Button {
selectedMode = mode
prompt = modeSamplePrompt(mode)
insightText = oracleInsight(for: mode)
} label: {
Label(mode.rawValue, systemImage: mode.icon)
}
}
} label: {
HStack(spacing: 5) {
Image(systemName: selectedMode.icon).font(.system(size: 10))
Text(selectedMode.rawValue).font(.system(size: 11, weight: .medium))
Image(systemName: "chevron.down").font(.system(size: 8))
}
.foregroundStyle(Color(red: 0.58, green: 0.77, blue: 0.99))
.padding(.horizontal, 10).padding(.vertical, 6)
.background(Capsule().fill(VelocityTheme.accent.opacity(0.14))
.overlay(Capsule().stroke(VelocityTheme.accent.opacity(0.3), lineWidth: 1)))
}
Spacer()
Button(action: submitPrompt) {
ZStack {
Circle()
.fill(isSubmitting ? VelocityTheme.mutedFg : VelocityTheme.accent)
.shadow(color: VelocityTheme.accent.opacity(0.5), radius: 8)
if isSubmitting {
ProgressView().progressViewStyle(CircularProgressViewStyle(tint: .white)).scaleEffect(0.6)
} else {
Image(systemName: "paperplane.fill").font(.system(size: 12)).foregroundStyle(.white)
}
}
.frame(width: 34, height: 34)
}
.disabled(isSubmitting || prompt.trimmingCharacters(in: .whitespaces).isEmpty)
}
.padding(.horizontal, 12).padding(.bottom, 12)
}
.background(
RoundedRectangle(cornerRadius: 18)
.fill(Color(red: 0.039, green: 0.043, blue: 0.063).opacity(0.95))
.overlay(RoundedRectangle(cornerRadius: 18).stroke(Color.white.opacity(0.11), lineWidth: 1))
.shadow(color: .black.opacity(0.6), radius: 20, y: -4)
)
}
// MARK: Prompt logic
private func submitPrompt() {
let clean = prompt.trimmingCharacters(in: .whitespaces)
guard !clean.isEmpty && !isSubmitting else { return }
isSubmitting = true
let lower = clean.lowercased()
if lower.contains("team") || lower.contains("performance") || lower.contains("sales") {
selectedMode = .teamPerformance
} else if lower.contains("account") || lower.contains("apex") || lower.contains("timeline") {
selectedMode = .accountTimeline
} else if lower.contains("map") || lower.contains("geographic") || lower.contains("location") {
selectedMode = .leadMap
} else if lower.contains("calendar") || lower.contains("schedule") || lower.contains("task") {
selectedMode = .calendarTasks
} else {
selectedMode = .pipeline
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.9) {
withAnimation(.easeInOut(duration: 0.3)) {
insightText = oracleInsight(for: selectedMode)
isSubmitting = false
}
}
}
private func modeSamplePrompt(_ mode: OracleMode) -> String {
switch mode {
case .pipeline: return "Show me a pipeline view by stage for Q4."
case .teamPerformance: return "What's the performance of the sales team this month?"
case .accountTimeline: return "Find all contacts at Apex Innovations and their recent activity."
case .leadMap: return "Give me a geographic map of all leads."
case .calendarTasks: return "Schedule follow-ups with the top 3 high-value leads."
}
}
private func oracleInsight(for mode: OracleMode) -> String {
switch mode {
case .pipeline: return "Pipeline is healthy. Mohammed Al-Rashid is your highest-value close opportunity — follow up within 24 hours."
case .teamPerformance: return "Rania Al-Farsi leads the team at $2.1M closed. Overall quota attainment is at 87% — ahead of last month."
case .accountTimeline: return "Apex Innovations has 4 active stakeholders. Confusion detected in legal stage — recommend expediting contract review."
case .leadMap: return "UAE and India show the hottest lead concentration. 8 high-value prospects in UAE require immediate outreach."
case .calendarTasks: return "3 high-priority follow-ups scheduled. Mohammed Al-Rashid has 2 unread messages — respond within 24h."
}
}
}
// MARK: Pipeline Canvas
private struct PipelineCanvas: View {
let onSelectLead: (OracleLeadCard) -> Void
private let cols = [GridItem(.adaptive(minimum: 200), spacing: 12)]
var body: some View {
LazyVGrid(columns: cols, alignment: .leading, spacing: 12) {
ForEach(pipelineData, id: \.stage) { col in
VStack(alignment: .leading, spacing: 10) {
HStack {
Text(col.stage.uppercased())
.font(.system(size: 10, weight: .semibold)).tracking(1)
.foregroundStyle(VelocityTheme.mutedFg)
Spacer()
Text("\(col.cards.count)")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(VelocityTheme.accent)
.padding(.horizontal, 7).padding(.vertical, 3)
.background(Capsule().fill(VelocityTheme.accent.opacity(0.12))
.overlay(Capsule().stroke(VelocityTheme.accent.opacity(0.2), lineWidth: 1)))
}
ForEach(col.cards) { card in
TappableLeadCard(card: card, onTap: { onSelectLead(card) })
}
}
.padding(16)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderSubtle, lineWidth: 1))
)
}
}
}
}
private struct TappableLeadCard: View {
let card: OracleLeadCard
let onTap: () -> Void
@State private var pressed = false
var body: some View {
HStack(spacing: 10) {
ZStack {
RoundedRectangle(cornerRadius: 8).fill(card.status.color.opacity(0.18)).frame(width: 36, height: 36)
Text(card.initials).font(.system(size: 12, weight: .bold)).foregroundStyle(card.status.color)
}
VStack(alignment: .leading, spacing: 2) {
Text(card.name).font(.system(size: 12, weight: .medium)).foregroundStyle(VelocityTheme.foreground)
Text(card.company).font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(card.value).font(.system(size: 11, weight: .semibold)).foregroundStyle(VelocityTheme.accent)
Image(systemName: "chevron.right").font(.system(size: 9)).foregroundStyle(VelocityTheme.mutedFg)
}
}
.padding(10)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(pressed ? VelocityTheme.accent.opacity(0.10) : Color.white.opacity(0.04))
.overlay(RoundedRectangle(cornerRadius: 10)
.stroke(pressed ? VelocityTheme.accent.opacity(0.35) : Color.white.opacity(0.06), lineWidth: 1))
)
.scaleEffect(pressed ? 0.97 : 1.0)
.animation(.easeInOut(duration: 0.12), value: pressed)
.onTapGesture {
pressed = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { pressed = false }
onTap()
}
}
}
// MARK: Lead Detail Sheet
private struct LeadDetailSheet: View {
let card: OracleLeadCard
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
VStack(alignment: .leading, spacing: 20) {
// Avatar + name
HStack(spacing: 16) {
ZStack {
RoundedRectangle(cornerRadius: 14).fill(card.status.color.opacity(0.20)).frame(width: 60, height: 60)
Text(card.initials).font(.system(size: 22, weight: .bold)).foregroundStyle(card.status.color)
}
VStack(alignment: .leading, spacing: 4) {
Text(card.name).font(.system(size: 20, weight: .bold)).foregroundStyle(VelocityTheme.foreground)
HStack(spacing: 6) {
Text(card.status.rawValue)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(card.status.color)
.padding(.horizontal, 8).padding(.vertical, 3)
.background(Capsule().fill(card.status.color.opacity(0.14)))
Text(card.qualification).font(.system(size: 11)).foregroundStyle(VelocityTheme.mutedFg)
}
}
}
.padding(.top, 8)
Divider().background(VelocityTheme.borderSubtle)
// Details grid
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 14) {
DetailField(label: "Deal Value", value: card.value)
DetailField(label: "Source", value: card.company)
DetailField(label: "Interest", value: card.interest)
DetailField(label: "Phone", value: card.phone)
}
Divider().background(VelocityTheme.borderSubtle)
// Action buttons
HStack(spacing: 12) {
ActionChip(icon: "phone.fill", label: "Call", color: VelocityTheme.success)
ActionChip(icon: "message.fill", label: "Message", color: VelocityTheme.accent)
ActionChip(icon: "calendar.badge.plus", label: "Schedule", color: Color(red: 0.60, green: 0.57, blue: 0.99))
}
Spacer()
}
.padding(24)
.background(VelocityTheme.background.ignoresSafeArea())
.navigationTitle("Lead Details")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") { dismiss() }
.foregroundStyle(VelocityTheme.accent)
}
}
}
}
}
private struct DetailField: View {
let label: String; let value: String
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(label.uppercased()).font(.system(size: 9, weight: .semibold)).tracking(1)
.foregroundStyle(VelocityTheme.mutedFg)
Text(value).font(.system(size: 13, weight: .medium)).foregroundStyle(VelocityTheme.foreground)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(RoundedRectangle(cornerRadius: 10).fill(Color.white.opacity(0.04))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.white.opacity(0.07), lineWidth: 1)))
}
}
private struct ActionChip: View {
let icon: String; let label: String; let color: Color
@State private var pressed = false
var body: some View {
HStack(spacing: 6) {
Image(systemName: icon).font(.system(size: 12))
Text(label).font(.system(size: 12, weight: .semibold))
}
.foregroundStyle(color)
.padding(.horizontal, 16).padding(.vertical, 10)
.frame(maxWidth: .infinity)
.background(RoundedRectangle(cornerRadius: 10).fill(color.opacity(pressed ? 0.22 : 0.13))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(color.opacity(0.30), lineWidth: 1)))
.scaleEffect(pressed ? 0.96 : 1.0)
.animation(.easeInOut(duration: 0.12), value: pressed)
.onTapGesture { pressed = true; DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { pressed = false } }
}
}
// MARK: Team Performance Canvas
private struct TeamPerformanceCanvas: View {
let onSelectMember: (TeamMemberData) -> Void
var body: some View {
VStack(spacing: 14) {
quotaPanel
teamListPanel
}
}
private var quotaPanel: some View {
HStack(spacing: 14) {
ZStack {
Circle().stroke(Color.white.opacity(0.06), lineWidth: 10).frame(width: 110, height: 110)
Circle()
.trim(from: 0, to: 0.87)
.stroke(AngularGradient(colors: [VelocityTheme.accent, Color(red: 0.13, green: 0.83, blue: 0.93)],
center: .center),
style: StrokeStyle(lineWidth: 10, lineCap: .round))
.rotationEffect(.degrees(-90))
.frame(width: 110, height: 110)
VStack(spacing: 2) {
Text("87%").font(.system(size: 26, weight: .bold)).foregroundStyle(VelocityTheme.foreground)
Text("QUOTA").font(.system(size: 8, weight: .semibold)).tracking(1.2).foregroundStyle(VelocityTheme.accent)
}
}
VStack(alignment: .leading, spacing: 4) {
Text("Quota Attainment").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
Text("Monthly target exceeded").font(.system(size: 11)).foregroundStyle(VelocityTheme.success)
Text("Q4 FY202526").font(.system(size: 11)).foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
}
.padding(20)
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
}
private var teamListPanel: some View {
VStack(alignment: .leading, spacing: 2) {
Text("TEAM PERFORMANCE").font(.system(size: 10, weight: .semibold)).tracking(1.2)
.foregroundStyle(VelocityTheme.mutedFg).padding(.bottom, 8)
ForEach(teamData) { member in
TappableTeamRow(member: member, onTap: { onSelectMember(member) })
}
}
.padding(20)
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
}
}
private struct TappableTeamRow: View {
let member: TeamMemberData
let onTap: () -> Void
@State private var pressed = false
var body: some View {
HStack(spacing: 12) {
ZStack {
RoundedRectangle(cornerRadius: 8).fill(VelocityTheme.accent.opacity(0.18)).frame(width: 36, height: 36)
Text(member.initials).font(.system(size: 12, weight: .bold)).foregroundStyle(VelocityTheme.accent)
}
VStack(alignment: .leading, spacing: 2) {
Text(member.name).font(.system(size: 13, weight: .medium)).foregroundStyle(VelocityTheme.foreground)
Text("\(member.deals) deals closed").font(.system(size: 11)).foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(member.revenue).font(.system(size: 12, weight: .semibold)).foregroundStyle(VelocityTheme.accent)
Text(member.trend)
.font(.system(size: 10, weight: .medium))
.foregroundStyle(member.trend.hasPrefix("") ? VelocityTheme.success :
member.trend.hasPrefix("") ? VelocityTheme.danger : VelocityTheme.mutedFg)
}
Image(systemName: "chevron.right").font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg)
}
.padding(12)
.background(RoundedRectangle(cornerRadius: 10)
.fill(pressed ? VelocityTheme.accent.opacity(0.08) : Color.white.opacity(0.04))
.overlay(RoundedRectangle(cornerRadius: 10)
.stroke(pressed ? VelocityTheme.accent.opacity(0.25) : Color.white.opacity(0.06), lineWidth: 1)))
.scaleEffect(pressed ? 0.98 : 1.0)
.animation(.easeInOut(duration: 0.12), value: pressed)
.onTapGesture {
pressed = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { pressed = false }
onTap()
}
}
}
// MARK: Team Member Detail Sheet
private struct MemberDetailSheet: View {
let member: TeamMemberData
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
VStack(alignment: .leading, spacing: 20) {
HStack(spacing: 16) {
ZStack {
RoundedRectangle(cornerRadius: 14).fill(VelocityTheme.accent.opacity(0.18)).frame(width: 60, height: 60)
Text(member.initials).font(.system(size: 22, weight: .bold)).foregroundStyle(VelocityTheme.accent)
}
VStack(alignment: .leading, spacing: 4) {
Text(member.name).font(.system(size: 20, weight: .bold)).foregroundStyle(VelocityTheme.foreground)
Text("Sales Executive").font(.system(size: 12)).foregroundStyle(VelocityTheme.mutedFg)
}
}
.padding(.top, 8)
Divider().background(VelocityTheme.borderSubtle)
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 14) {
DetailField(label: "Revenue Closed", value: member.revenue)
DetailField(label: "Deals Closed", value: "\(member.deals)")
DetailField(label: "Trend", value: member.trend)
DetailField(label: "Period", value: "Q4 FY202526")
}
Spacer()
}
.padding(24)
.background(VelocityTheme.background.ignoresSafeArea())
.navigationTitle("Team Member")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") { dismiss() }.foregroundStyle(VelocityTheme.accent)
}
}
}
}
}
// MARK: Account Timeline Canvas
private struct AccountTimelineCanvas: View {
@State private var expandedId: UUID? = nil
var body: some View {
VStack(spacing: 14) {
// Account overview
VStack(alignment: .leading, spacing: 12) {
Text("ACCOUNT OVERVIEW").font(.system(size: 9, weight: .semibold)).tracking(1.5).foregroundStyle(VelocityTheme.accent)
Text("Apex Innovations").font(.system(size: 24, weight: .bold)).foregroundStyle(VelocityTheme.foreground)
HStack(spacing: 14) {
InfoMini(label: "Deal Value", value: "AED 15M+")
InfoMini(label: "Primary Contact", value: "CEO James T.")
InfoMini(label: "Industry", value: "Technology")
}
}
.padding(20)
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
// Expandable timeline
VStack(alignment: .leading, spacing: 0) {
Text("Activity Timeline").font(.system(size: 13, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground).padding(.bottom, 16)
ForEach(Array(timelineEvents.enumerated()), id: \.offset) { i, event in
TimelineEventRow(event: event, isLast: i == timelineEvents.count - 1,
isExpanded: expandedId == event.id) {
withAnimation(.easeInOut(duration: 0.25)) {
expandedId = expandedId == event.id ? nil : event.id
}
}
}
}
.padding(20)
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
}
}
}
private struct TimelineEventRow: View {
let event: OracleTimelineEvent
let isLast: Bool
let isExpanded: Bool
let onTap: () -> Void
var body: some View {
HStack(alignment: .top, spacing: 14) {
VStack(spacing: 0) {
Circle().fill(VelocityTheme.accent).frame(width: 10, height: 10)
.overlay(Circle().stroke(VelocityTheme.background, lineWidth: 2))
if !isLast {
Rectangle()
.fill(LinearGradient(colors: [VelocityTheme.accent.opacity(0.5), .clear],
startPoint: .top, endPoint: .bottom))
.frame(width: 2)
.frame(height: isExpanded ? 100 : 50)
.animation(.easeInOut(duration: 0.25), value: isExpanded)
}
}
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(event.badge).font(.system(size: 9, weight: .bold)).tracking(1.2)
.foregroundStyle(VelocityTheme.accent)
.padding(.horizontal, 7).padding(.vertical, 3)
.background(RoundedRectangle(cornerRadius: 4).fill(VelocityTheme.accent.opacity(0.15))
.overlay(RoundedRectangle(cornerRadius: 4).stroke(VelocityTheme.accent.opacity(0.25), lineWidth: 1)))
Spacer()
Text(event.when).font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg)
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.system(size: 9)).foregroundStyle(VelocityTheme.mutedFg)
}
Text(event.summary).font(.system(size: 13, weight: .medium)).foregroundStyle(VelocityTheme.foreground)
if isExpanded {
Text(event.detail).font(.system(size: 12)).foregroundStyle(VelocityTheme.mutedFg)
.padding(.top, 4)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
.padding(12)
.background(RoundedRectangle(cornerRadius: 10)
.fill(isExpanded ? VelocityTheme.accent.opacity(0.06) : Color.white.opacity(0.04))
.overlay(RoundedRectangle(cornerRadius: 10)
.stroke(isExpanded ? VelocityTheme.accent.opacity(0.2) : Color.white.opacity(0.06), lineWidth: 1)))
.onTapGesture { onTap() }
.padding(.bottom, 8)
}
}
}
private struct InfoMini: View {
let label: String; let value: String
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(label).font(.system(size: 9)).tracking(0.8).foregroundStyle(VelocityTheme.mutedFg)
Text(value).font(.system(size: 12, weight: .semibold)).foregroundStyle(VelocityTheme.accent)
}
.padding(10)
.background(RoundedRectangle(cornerRadius: 8).fill(Color.white.opacity(0.04))
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.white.opacity(0.06), lineWidth: 1)))
}
}
// MARK: Lead Map Canvas
private struct LeadMapCanvas: View {
let onSelectRegion: (RegionPin) -> Void
private let cols = [GridItem(.adaptive(minimum: 140), spacing: 10)]
var body: some View {
VStack(alignment: .leading, spacing: 14) {
HStack(spacing: 16) {
LegendDot(color: VelocityTheme.danger, label: "Hot Lead")
LegendDot(color: Color(red: 0.13, green: 0.83, blue: 0.93), label: "Warm Lead")
LegendDot(color: VelocityTheme.mutedFg, label: "Cold Lead")
Spacer()
}
LazyVGrid(columns: cols, spacing: 10) {
ForEach(mapPins) { pin in
TappableRegionPin(pin: pin, onTap: { onSelectRegion(pin) })
}
}
}
.padding(20)
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
}
}
private struct TappableRegionPin: View {
let pin: RegionPin
let onTap: () -> Void
@State private var pressed = false
private var pinColor: Color {
pin.temp == "hot" ? VelocityTheme.danger :
pin.temp == "warm" ? Color(red: 0.13, green: 0.83, blue: 0.93) : VelocityTheme.mutedFg
}
var body: some View {
HStack(spacing: 10) {
Text(pin.country).font(.system(size: 24))
VStack(alignment: .leading, spacing: 2) {
Text(pin.label).font(.system(size: 12, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
HStack(spacing: 4) {
Circle().fill(pinColor).frame(width: 6, height: 6).shadow(color: pinColor.opacity(0.8), radius: 3)
Text("\(pin.count) leads").font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg)
}
}
Spacer()
Image(systemName: "arrow.up.right.circle")
.font(.system(size: 13)).foregroundStyle(VelocityTheme.mutedFg)
}
.padding(12)
.background(RoundedRectangle(cornerRadius: 10)
.fill(pressed ? pinColor.opacity(0.12) : Color(red: 0.031, green: 0.039, blue: 0.071))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(pinColor.opacity(pressed ? 0.5 : 0.25), lineWidth: 1)))
.scaleEffect(pressed ? 0.97 : 1.0)
.animation(.easeInOut(duration: 0.12), value: pressed)
.onTapGesture {
pressed = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { pressed = false }
onTap()
}
}
}
private struct LegendDot: View {
let color: Color; let label: String
var body: some View {
HStack(spacing: 6) {
Circle().fill(color).frame(width: 8, height: 8).shadow(color: color.opacity(0.8), radius: 3)
Text(label).font(.system(size: 11)).foregroundStyle(VelocityTheme.mutedFg)
}
}
}
// MARK: Region Detail Sheet
private struct RegionDetailSheet: View {
let pin: RegionPin
@Environment(\.dismiss) private var dismiss
private var pinColor: Color {
pin.temp == "hot" ? VelocityTheme.danger :
pin.temp == "warm" ? Color(red: 0.13, green: 0.83, blue: 0.93) : VelocityTheme.mutedFg
}
var body: some View {
NavigationStack {
VStack(alignment: .leading, spacing: 20) {
HStack(spacing: 16) {
Text(pin.country).font(.system(size: 52))
VStack(alignment: .leading, spacing: 4) {
Text(pin.label).font(.system(size: 22, weight: .bold)).foregroundStyle(VelocityTheme.foreground)
HStack(spacing: 6) {
Circle().fill(pinColor).frame(width: 7, height: 7)
Text(pin.temp.capitalized + " Market")
.font(.system(size: 12, weight: .medium)).foregroundStyle(pinColor)
}
}
}
.padding(.top, 8)
Divider().background(VelocityTheme.borderSubtle)
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 14) {
DetailField(label: "Active Leads", value: "\(pin.count)")
DetailField(label: "Top Lead", value: pin.topLead)
DetailField(label: "Temperature", value: pin.temp.capitalized)
DetailField(label: "Priority", value: pin.temp == "hot" ? "High 🔴" : pin.temp == "warm" ? "Medium 🟡" : "Low ⚪")
}
Spacer()
}
.padding(24)
.background(VelocityTheme.background.ignoresSafeArea())
.navigationTitle("Region Details")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") { dismiss() }.foregroundStyle(VelocityTheme.accent)
}
}
}
}
}
// MARK: Calendar Canvas
private struct CalendarCanvas: View {
let onSchedule: (CalTask) -> Void
let days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
var body: some View {
VStack(spacing: 14) {
weekPanel
tasksPanel
}
}
private var weekPanel: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Weekly Schedule").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
HStack(spacing: 6) {
ForEach(days, id: \.self) { day in
Text(day).font(.system(size: 10, weight: .medium)).tracking(0.5)
.foregroundStyle(VelocityTheme.mutedFg).frame(maxWidth: .infinity)
}
}
HStack(spacing: 6) {
ForEach(Array(days.enumerated()), id: \.offset) { i, _ in
RoundedRectangle(cornerRadius: 8)
.fill(i == 2 ? VelocityTheme.accent.opacity(0.15) : Color.white.opacity(0.03))
.overlay(RoundedRectangle(cornerRadius: 8)
.stroke(i == 2 ? VelocityTheme.accent.opacity(0.3) : Color.white.opacity(0.05), lineWidth: 1))
.frame(height: 60)
}
}
}
.padding(16)
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
}
private var tasksPanel: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 5) {
Circle().fill(Color(red: 0.13, green: 0.83, blue: 0.93)).frame(width: 6, height: 6)
Text("Tasks & Actions").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
}
.padding(.bottom, 4)
ForEach(calTasks) { task in
CalTaskRow(task: task, onSchedule: { onSchedule(task) })
}
}
.padding(16)
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
}
}
private struct CalTaskRow: View {
let task: CalTask
let onSchedule: () -> Void
@State private var scheduled = false
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(task.title).font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
Spacer()
Text(scheduled ? "Scheduled ✓" : "Action")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(scheduled ? VelocityTheme.success : VelocityTheme.mutedFg)
.padding(.horizontal, 6).padding(.vertical, 3)
.background(RoundedRectangle(cornerRadius: 4)
.fill(scheduled ? VelocityTheme.success.opacity(0.12) : Color.white.opacity(0.06)))
}
Text(task.subtitle).font(.system(size: 11)).foregroundStyle(VelocityTheme.mutedFg).lineLimit(2)
HStack {
Image(systemName: "clock").font(.system(size: 10)).foregroundStyle(VelocityTheme.accent)
Text(task.due).font(.system(size: 11)).foregroundStyle(VelocityTheme.accent)
Spacer()
Button {
onSchedule()
withAnimation(.easeInOut(duration: 0.3)) { scheduled = true }
} label: {
HStack(spacing: 5) {
Image(systemName: scheduled ? "checkmark" : "calendar.badge.plus")
.font(.system(size: 10, weight: .semibold))
Text(scheduled ? "Scheduled" : "Schedule")
.font(.system(size: 11, weight: .semibold))
}
.foregroundStyle(.white)
.padding(.horizontal, 12).padding(.vertical, 5)
.background(RoundedRectangle(cornerRadius: 7)
.fill(scheduled ? VelocityTheme.success : VelocityTheme.accent)
.shadow(color: (scheduled ? VelocityTheme.success : VelocityTheme.accent).opacity(0.4), radius: 6))
}
}
}
.padding(12)
.background(RoundedRectangle(cornerRadius: 10)
.fill(scheduled ? VelocityTheme.success.opacity(0.05) : Color.white.opacity(0.04))
.overlay(RoundedRectangle(cornerRadius: 10)
.stroke(scheduled ? VelocityTheme.success.opacity(0.2) : Color.white.opacity(0.06), lineWidth: 1)))
}
}

View File

@@ -1,413 +0,0 @@
import SwiftUI
struct SentinelView: View {
private var store: AppStore { AppStore.shared }
private let indigo = Color(red: 0.60, green: 0.57, blue: 0.99)
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
pageHeader
kpiGrid
analyticsRow
bottomRow
}
.padding(24)
}
.background(VelocityTheme.background)
.scrollContentBackground(.hidden)
}
// MARK: Sub-views extracted so the type-checker can cope
private var pageHeader: some View {
VStack(alignment: .leading, spacing: 4) {
Text("Sentinel")
.font(.system(size: 28, weight: .bold)).foregroundStyle(VelocityTheme.foreground)
Text("FaceID · visitor analytics · real-time alerts")
.font(.system(size: 12)).foregroundStyle(VelocityTheme.mutedFg)
}
}
private var kpiGrid: some View {
let cols = [GridItem(.adaptive(minimum: 180), spacing: 12)]
return LazyVGrid(columns: cols, spacing: 12) {
SentinelKPI(icon: "person.2.fill", iconColor: VelocityTheme.accent,
label: "Active Visitors", value: "\(store.visitors.count)",
sub: "Currently tracked", badge: "LIVE")
SentinelKPI(icon: "waveform.path.ecg", iconColor: VelocityTheme.success,
label: "Avg Sentiment", value: "\(avgSentiment)%",
sub: "Overall mood")
SentinelKPI(icon: "eye.fill", iconColor: indigo,
label: "Detection Accuracy", value: "\(avgConfidence)%",
sub: "Avg confidence")
SentinelKPI(icon: "faceid", iconColor: VelocityTheme.warning,
label: "Tracked Today", value: "47",
sub: "Unique faces")
}
.animation(.easeInOut(duration: 0.4), value: store.visitors.count)
}
private var analyticsRow: some View {
let cols = [GridItem(.flexible(), spacing: 14), GridItem(.flexible(minimum: 260), spacing: 14)]
return LazyVGrid(columns: cols, alignment: .leading, spacing: 14) {
ZoneAnalyticsPanel()
ClientInsightsPanel()
}
}
private var bottomRow: some View {
let cols = [GridItem(.flexible(), spacing: 14),
GridItem(.flexible(), spacing: 14),
GridItem(.flexible(), spacing: 14)]
return LazyVGrid(columns: cols, alignment: .leading, spacing: 14) {
SentimentDistributionPanel(visitors: store.visitors)
DwellTimePanel()
AlertPanel(isActive: store.isAlertActive, message: store.alertMessage)
}
}
private var avgSentiment: Int {
guard !store.visitors.isEmpty else { return 0 }
let total = store.visitors.reduce(0) { $0 + $1.sentiment.score }
return total / store.visitors.count
}
private var avgConfidence: Int {
guard !store.visitors.isEmpty else { return 0 }
let total = store.visitors.reduce(0.0) { $0 + $1.confidence }
return Int((total / Double(store.visitors.count)) * 100)
}
}
// MARK: KPI Card
private struct SentinelKPI: View {
let icon: String; let iconColor: Color
let label: String; let value: String; let sub: String
var badge: String? = nil
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
ZStack {
RoundedRectangle(cornerRadius: 8).fill(iconColor.opacity(0.14)).frame(width: 34, height: 34)
Image(systemName: icon).font(.system(size: 14)).foregroundStyle(iconColor)
}
Spacer()
if let badge {
HStack(spacing: 4) {
Circle().fill(VelocityTheme.success).frame(width: 5, height: 5)
Text(badge).font(.system(size: 9, weight: .semibold)).foregroundStyle(VelocityTheme.success)
}
}
}
Text(label.uppercased()).font(.system(size: 10, weight: .medium)).tracking(1.0)
.foregroundStyle(VelocityTheme.mutedFg)
Text(value).font(.system(size: 30, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
.contentTransition(.numericText())
Text(sub).font(.system(size: 11)).foregroundStyle(VelocityTheme.subtleFg)
}
.padding(18)
.frame(maxWidth: .infinity, minHeight: 140, alignment: .topLeading)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
.overlay(RoundedRectangle(cornerRadius: 14).stroke(iconColor.opacity(0.18), lineWidth: 1))
)
}
}
// MARK: Zone Analytics
private struct ZoneAnalyticsPanel: View {
private let zones: [(id: String, name: String, count: Int, sentiment: Int)] = [
("A", "Main Showroom", 5, 72),
("B", "Penthouse Gallery",3, 85),
("C", "Amenity Deck VR", 2, 68),
("D", "Reception", 2, 90),
]
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 6) {
Image(systemName: "mappin.and.ellipse").font(.system(size: 12)).foregroundStyle(VelocityTheme.accent)
Text("Zone Analytics").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
}
ForEach(zones, id: \.id) { zone in
HStack(spacing: 10) {
ZStack {
RoundedRectangle(cornerRadius: 6).fill(VelocityTheme.accent.opacity(0.14)).frame(width: 28, height: 28)
Text(zone.id).font(.system(size: 11, weight: .bold)).foregroundStyle(VelocityTheme.accent)
}
VStack(alignment: .leading, spacing: 2) {
Text(zone.name).font(.system(size: 12, weight: .medium)).foregroundStyle(VelocityTheme.foreground)
Text("\(zone.count) visitors").font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
HStack(spacing: 4) {
let c: Color = zone.sentiment >= 80 ? VelocityTheme.success :
zone.sentiment >= 60 ? VelocityTheme.accent : VelocityTheme.warning
Circle().fill(c).frame(width: 7, height: 7)
Text("\(zone.sentiment)%").font(.system(size: 12, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
}
}
.padding(10)
.background(RoundedRectangle(cornerRadius: 10).fill(Color.white.opacity(0.04))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.white.opacity(0.06), lineWidth: 1)))
}
}
.padding(16)
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
}
}
// MARK: Client Insights
private struct ClientInsightsPanel: View {
private struct Insight {
let name: String; let stage: String; let sentiment: String
let score: Int; let insight: String; let color: Color
var icon: String {
score >= 80 ? "arrow.up.right" : score >= 50 ? "minus" : "exclamationmark.triangle"
}
var scoreColor: Color {
score >= 80 ? VelocityTheme.success : score >= 50 ? VelocityTheme.warning : VelocityTheme.danger
}
}
private let insights: [Insight] = [
.init(name: "Quantum Dynamics", stage: "Proposal Review", sentiment: "Positive", score: 92,
insight: "Key decision maker showed high engagement. Emphasise custom penthouse integration.",
color: VelocityTheme.success),
.init(name: "Nebula Ventures", stage: "Discovery", sentiment: "Neutral", score: 45,
insight: "Initial interest detected but hesitation around pricing model tier.",
color: VelocityTheme.warning),
.init(name: "Apex Industries", stage: "Negotiation", sentiment: "Mixed", score: 68,
insight: "Confusion markers during contract terms review. Legal team is the blocker.",
color: VelocityTheme.danger),
.init(name: "Starlight Systems", stage: "Initial Contact", sentiment: "Positive", score: 78,
insight: "Strong ESG engagement. Align pitch with sustainability goals to accelerate close.",
color: VelocityTheme.accent),
]
var body: some View {
VStack(alignment: .leading, spacing: 12) {
insightHeader
insightGrid
}
.padding(16)
.background(RoundedRectangle(cornerRadius: 14)
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
.overlay(RoundedRectangle(cornerRadius: 14)
.stroke(VelocityTheme.borderAccent, lineWidth: 1)))
}
private var insightHeader: some View {
HStack(spacing: 6) {
Image(systemName: "sparkles").font(.system(size: 12)).foregroundStyle(VelocityTheme.accent)
Text("AI Strategic Insights")
.font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
Spacer()
Text("LIVE ANALYSIS").font(.system(size: 8, weight: .bold)).tracking(1)
.foregroundStyle(VelocityTheme.accent)
.padding(.horizontal, 6).padding(.vertical, 3)
.background(RoundedRectangle(cornerRadius: 4).fill(VelocityTheme.accent.opacity(0.12))
.overlay(RoundedRectangle(cornerRadius: 4)
.stroke(VelocityTheme.accent.opacity(0.2), lineWidth: 1)))
}
}
private var insightGrid: some View {
let cols = [GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10)]
return LazyVGrid(columns: cols, alignment: .leading, spacing: 10) {
ForEach(insights, id: \.name) { item in
InsightCard(
name: item.name, stage: item.stage, sentiment: item.sentiment,
score: item.score, insight: item.insight, color: item.color,
icon: item.icon, scoreColor: item.scoreColor
)
}
}
}
}
private struct InsightCard: View {
struct Item {
let name: String; let stage: String; let sentiment: String
let score: Int; let insight: String; let color: Color
var icon: String { score >= 80 ? "arrow.up.right" : score >= 50 ? "minus" : "exclamationmark.triangle" }
var scoreColor: Color { score >= 80 ? VelocityTheme.success : score >= 50 ? VelocityTheme.warning : VelocityTheme.danger }
}
// Accept ClientInsightsPanel.Insight via protocol duck-typing workaround:
let name: String; let stage: String; let sentiment: String
let score: Int; let insight: String; let color: Color
let icon: String; let scoreColor: Color
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
ZStack {
RoundedRectangle(cornerRadius: 6).fill(color.opacity(0.18)).frame(width: 28, height: 28)
Image(systemName: icon).font(.system(size: 11)).foregroundStyle(color)
}
Spacer()
Text("\(score)").font(.system(size: 11, weight: .bold))
.foregroundStyle(scoreColor)
.padding(.horizontal, 6).padding(.vertical, 2)
.background(RoundedRectangle(cornerRadius: 4).fill(scoreColor.opacity(0.15)))
}
Text(name).font(.system(size: 12, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground).lineLimit(1)
Text(insight).font(.system(size: 10))
.foregroundStyle(VelocityTheme.mutedFg).lineLimit(3)
HStack {
Text(stage).font(.system(size: 9)).foregroundStyle(VelocityTheme.subtleFg)
Spacer()
Text(sentiment).font(.system(size: 9, weight: .semibold)).foregroundStyle(color)
}
}
.padding(10)
.background(RoundedRectangle(cornerRadius: 10).fill(Color.white.opacity(0.04))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(color.opacity(0.15), lineWidth: 1)))
}
}
// MARK: Sentiment Distribution
private struct SentimentDistributionPanel: View {
let visitors: [Visitor]
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 6) {
Image(systemName: "chart.bar").font(.system(size: 12)).foregroundStyle(VelocityTheme.accent)
Text("Sentiment").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
}
ForEach(SentimentType.allCases, id: \.self) { type in
let count = visitors.filter { $0.sentiment == type }.count
let fraction = visitors.isEmpty ? 0.0 : Double(count) / Double(visitors.count)
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(type.emoji).font(.system(size: 14))
Text(type.rawValue.capitalized).font(.system(size: 11)).foregroundStyle(VelocityTheme.mutedFg)
Spacer()
Text("\(count)").font(.system(size: 11, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
}
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.06)).frame(height: 5)
RoundedRectangle(cornerRadius: 3).fill(type.color)
.frame(width: geo.size.width * fraction, height: 5)
.animation(.easeOut(duration: 0.6), value: fraction)
}
}
.frame(height: 5)
}
}
}
.padding(16)
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
}
}
// MARK: Dwell Time Panel
private struct DwellTimePanel: View {
private let data: [(range: String, count: Int, trend: String)] = [
("< 5 min", 3, "down"),
("515 min", 5, "up"),
("1530 min", 8, "up"),
("> 30 min", 4, "stable"),
]
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 6) {
Image(systemName: "timer").font(.system(size: 12)).foregroundStyle(VelocityTheme.accent)
Text("Dwell Time").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
}
let cols = [GridItem(.flexible(), spacing: 8), GridItem(.flexible(), spacing: 8)]
LazyVGrid(columns: cols, spacing: 8) {
ForEach(data, id: \.range) { item in
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(item.range).font(.system(size: 9)).foregroundStyle(VelocityTheme.mutedFg)
Spacer()
Image(systemName: item.trend == "up" ? "arrow.up.right" :
item.trend == "down" ? "arrow.down.right" : "minus")
.font(.system(size: 9))
.foregroundStyle(item.trend == "up" ? VelocityTheme.success :
item.trend == "down" ? VelocityTheme.danger : VelocityTheme.mutedFg)
}
Text("\(item.count)").font(.system(size: 22, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
Text("visitors").font(.system(size: 9)).foregroundStyle(VelocityTheme.subtleFg)
}
.padding(10)
.background(RoundedRectangle(cornerRadius: 10).fill(Color.white.opacity(0.04))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.white.opacity(0.06), lineWidth: 1)))
}
}
}
.padding(16)
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
}
}
// MARK: Alert Panel
private struct AlertPanel: View {
let isActive: Bool
let message: String
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 6) {
Image(systemName: "bell.badge").font(.system(size: 12)).foregroundStyle(VelocityTheme.warning)
Text("Alerts").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
Spacer()
Text(isActive ? "Active" : "Clear")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(isActive ? VelocityTheme.warning : VelocityTheme.success)
.padding(.horizontal, 7).padding(.vertical, 3)
.background(Capsule().fill((isActive ? VelocityTheme.warning : VelocityTheme.success).opacity(0.15))
.overlay(Capsule().stroke((isActive ? VelocityTheme.warning : VelocityTheme.success).opacity(0.25), lineWidth: 1)))
}
if isActive {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 14)).foregroundStyle(VelocityTheme.warning)
Text("Sentiment Alert").font(.system(size: 12, weight: .semibold)).foregroundStyle(VelocityTheme.warning)
}
Text(message).font(.system(size: 12)).foregroundStyle(VelocityTheme.foreground).lineLimit(3)
Text("Just now").font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg)
}
.padding(12)
.background(RoundedRectangle(cornerRadius: 10).fill(VelocityTheme.warning.opacity(0.08))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(VelocityTheme.warning.opacity(0.3), lineWidth: 1)))
.transition(.asymmetric(insertion: .scale(scale: 0.95).combined(with: .opacity),
removal: .scale(scale: 0.95).combined(with: .opacity)))
} else {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Image(systemName: "checkmark.shield.fill")
.font(.system(size: 14)).foregroundStyle(VelocityTheme.success)
Text("All Clear").font(.system(size: 12, weight: .semibold)).foregroundStyle(VelocityTheme.success)
}
Text("No alerts at this time").font(.system(size: 12)).foregroundStyle(VelocityTheme.mutedFg)
Text("System nominal").font(.system(size: 10)).foregroundStyle(VelocityTheme.subtleFg)
}
.padding(12)
.background(RoundedRectangle(cornerRadius: 10).fill(VelocityTheme.success.opacity(0.08))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(VelocityTheme.success.opacity(0.3), lineWidth: 1)))
.transition(.asymmetric(insertion: .scale(scale: 0.95).combined(with: .opacity),
removal: .scale(scale: 0.95).combined(with: .opacity)))
}
}
.padding(16)
.animation(.easeInOut(duration: 0.3), value: isActive)
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
}
}

View File

@@ -1,141 +0,0 @@
import SwiftUI
struct SettingsView: View {
var body: some View {
VStack(alignment: .leading, spacing: 24) {
// Page header
VStack(alignment: .leading, spacing: 4) {
Text("Settings")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text("Configuration")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
// System (live) section
SettingsSection(title: "System") {
HStack(spacing: 14) {
ZStack {
RoundedRectangle(cornerRadius: 7)
.fill(VelocityTheme.success.opacity(0.12)).frame(width: 30, height: 30)
Image(systemName: "bolt.fill")
.font(.system(size: 13, weight: .medium)).foregroundStyle(VelocityTheme.success)
}
Text("Connection Status").font(.system(size: 14)).foregroundStyle(VelocityTheme.foreground)
Spacer()
HStack(spacing: 5) {
Circle().fill(VelocityTheme.success).frame(width: 6, height: 6)
Text("Online").font(.system(size: 12, weight: .medium)).foregroundStyle(VelocityTheme.success)
}
}
.padding(.horizontal, 16).padding(.vertical, 12)
}
// Backend section
SettingsSection(title: "Backend") {
SettingsRow(label: "ComfyUI Endpoint",
value: "http://192.168.x.x:8000",
icon: "server.rack",
accentColor: VelocityTheme.accent)
Divider().background(VelocityTheme.borderSubtle)
SettingsRow(label: "Dream Weaver Path",
value: "/dream-weaver",
icon: "arrow.triangle.branch",
accentColor: VelocityTheme.accent)
}
// Display section
SettingsSection(title: "Display") {
SettingsRow(label: "Orientation",
value: "Landscape Only",
icon: "rectangle.landscape.rotate",
accentColor: VelocityTheme.mutedFg)
Divider().background(VelocityTheme.borderSubtle)
SettingsRow(label: "Theme",
value: "Dark",
icon: "moon.fill",
accentColor: Color(red: 0.60, green: 0.57, blue: 0.99))
}
// App info section
SettingsSection(title: "About") {
SettingsRow(label: "Version",
value: "1.1.0",
icon: "info.circle",
accentColor: VelocityTheme.mutedFg)
Divider().background(VelocityTheme.borderSubtle)
SettingsRow(label: "Build",
value: "SwiftUI · iOS 17+",
icon: "hammer",
accentColor: VelocityTheme.mutedFg)
}
Spacer()
}
.padding(24)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(VelocityTheme.background)
}
}
private struct SettingsSection<Content: View>: View {
let title: String
@ViewBuilder let content: Content
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Text(title.uppercased())
.font(.system(size: 10, weight: .semibold))
.tracking(1.2)
.foregroundStyle(VelocityTheme.mutedFg)
.padding(.bottom, 8)
.padding(.horizontal, 4)
VStack(spacing: 0) {
content
}
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
)
)
}
}
}
private struct SettingsRow: View {
let label: String
let value: String
let icon: String
let accentColor: Color
var body: some View {
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)
}
Text(label)
.font(.system(size: 14))
.foregroundStyle(VelocityTheme.foreground)
Spacer()
Text(value)
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}

10
iOS/README.md Normal file
View File

@@ -0,0 +1,10 @@
# Velocity iOS Source Of Truth
The active iPad application source is:
- `iOS/velocity-ipad/velocity`
- `iOS/velocity-ipad/velocity.xcodeproj`
- `iOS/velocity-ipad/velocityTests`
The root-level prototype source folders that previously duplicated iPad code have been removed to prevent drift. The separate `iOS/velocity-iphone` target is intentionally retained as a distinct iPhone companion app.

View File

@@ -8,10 +8,23 @@
/* Begin PBXBuildFile section */
A27B23462F58DAF100A74A49 /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = A27B23452F58DAF100A74A49 /* Alamofire */; };
B31D10012F6A000100000004 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B31D10012F6A000100000002 /* XCTest.framework */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
B31D10012F6A000100000005 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = A27B230D2F58D9C300A74A49 /* Project object */;
proxyType = 1;
remoteGlobalIDString = A27B23142F58D9C300A74A49;
remoteInfo = velocity;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
A27B23152F58D9C300A74A49 /* velocity.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = velocity.app; sourceTree = BUILT_PRODUCTS_DIR; };
B31D10012F6A000100000001 /* velocityTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = velocityTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
B31D10012F6A000100000002 /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -33,6 +46,11 @@
path = velocity;
sourceTree = "<group>";
};
B31D10012F6A000100000003 /* velocityTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = velocityTests;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -44,6 +62,14 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
B31D10012F6A000100000008 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
B31D10012F6A000100000004 /* XCTest.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -51,6 +77,8 @@
isa = PBXGroup;
children = (
A27B23172F58D9C300A74A49 /* velocity */,
B31D10012F6A000100000003 /* velocityTests */,
B31D10012F6A00010000000E /* Frameworks */,
A27B23162F58D9C300A74A49 /* Products */,
);
sourceTree = "<group>";
@@ -59,10 +87,19 @@
isa = PBXGroup;
children = (
A27B23152F58D9C300A74A49 /* velocity.app */,
B31D10012F6A000100000001 /* velocityTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
B31D10012F6A00010000000E /* Frameworks */ = {
isa = PBXGroup;
children = (
B31D10012F6A000100000002 /* XCTest.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -89,6 +126,29 @@
productReference = A27B23152F58D9C300A74A49 /* velocity.app */;
productType = "com.apple.product-type.application";
};
B31D10012F6A00010000000A /* velocityTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = B31D10012F6A00010000000B /* Build configuration list for PBXNativeTarget "velocityTests" */;
buildPhases = (
B31D10012F6A000100000007 /* Sources */,
B31D10012F6A000100000008 /* Frameworks */,
B31D10012F6A000100000009 /* Resources */,
);
buildRules = (
);
dependencies = (
B31D10012F6A000100000006 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
B31D10012F6A000100000003 /* velocityTests */,
);
name = velocityTests;
packageProductDependencies = (
);
productName = velocityTests;
productReference = B31D10012F6A000100000001 /* velocityTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -102,6 +162,9 @@
A27B23142F58D9C300A74A49 = {
CreatedOnToolsVersion = 26.3;
};
B31D10012F6A00010000000A = {
CreatedOnToolsVersion = 26.3;
};
};
};
buildConfigurationList = A27B23102F58D9C300A74A49 /* Build configuration list for PBXProject "velocity" */;
@@ -122,6 +185,7 @@
projectRoot = "";
targets = (
A27B23142F58D9C300A74A49 /* velocity */,
B31D10012F6A00010000000A /* velocityTests */,
);
};
/* End PBXProject section */
@@ -134,6 +198,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
B31D10012F6A000100000009 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -144,8 +215,23 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
B31D10012F6A000100000007 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
B31D10012F6A000100000006 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = A27B23142F58D9C300A74A49 /* velocity */;
targetProxy = B31D10012F6A000100000005 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
A27B231E2F58D9C400A74A49 /* Debug */ = {
isa = XCBuildConfiguration;
@@ -199,7 +285,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
@@ -256,7 +342,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
@@ -278,19 +364,17 @@
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = velocity/Info.plist;
INFOPLIST_KEY_NSCameraUsageDescription = "\"Used to capture rooms for the DreamWeaver AI redesign feature.\"";
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "\"Used to calculate the sun's path for the Sunseeker AR overlay.\"\n\"Used to calculate the sun's path for the Sunseeker AR overlay.\"\n\"Used to calculate the sun's path for the Sunseeker AR overlay.\"\n";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.desineouron.velocity;
MARKETING_VERSION = 1.1;
PRODUCT_BUNDLE_IDENTIFIER = com.desineuron.velocity.ipad;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -298,7 +382,7 @@
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TARGETED_DEVICE_FAMILY = 2;
};
name = Debug;
};
@@ -314,19 +398,17 @@
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = velocity/Info.plist;
INFOPLIST_KEY_NSCameraUsageDescription = "\"Used to capture rooms for the DreamWeaver AI redesign feature.\"";
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "\"Used to calculate the sun's path for the Sunseeker AR overlay.\"\n\"Used to calculate the sun's path for the Sunseeker AR overlay.\"\n\"Used to calculate the sun's path for the Sunseeker AR overlay.\"\n";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.desineouron.velocity;
MARKETING_VERSION = 1.1;
PRODUCT_BUNDLE_IDENTIFIER = com.desineuron.velocity.ipad;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -334,7 +416,59 @@
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TARGETED_DEVICE_FAMILY = 2;
};
name = Release;
};
B31D10012F6A00010000000C /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = L29922NHD9;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 1.1;
PRODUCT_BUNDLE_IDENTIFIER = com.desineuron.velocity.ipadTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 2;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/velocity.app/velocity";
};
name = Debug;
};
B31D10012F6A00010000000D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = L29922NHD9;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 1.1;
PRODUCT_BUNDLE_IDENTIFIER = com.desineuron.velocity.ipadTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 2;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/velocity.app/velocity";
};
name = Release;
};
@@ -359,6 +493,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
B31D10012F6A00010000000B /* Build configuration list for PBXNativeTarget "velocityTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
B31D10012F6A00010000000C /* Debug */,
B31D10012F6A00010000000D /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */

View File

@@ -0,0 +1,42 @@
import SwiftUI
struct ConfigurationGateView: View {
var body: some View {
ZStack {
VelocityTheme.background.ignoresSafeArea()
VStack(spacing: 24) {
VStack(spacing: 10) {
Text("Configure Velocity")
.font(.system(size: 34, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text("This iPad now expects a real runtime session. Add the production endpoint and operator credentials before live data can load.")
.font(.system(size: 14))
.foregroundStyle(VelocityTheme.mutedFg)
.multilineTextAlignment(.center)
.frame(maxWidth: 700)
}
SessionConfigurationPanel(
title: "Secure Session Setup",
subtitle: "Runtime credentials replace the old build-time-only configuration path. Velocity saves secrets in Keychain and immediately tries a live refresh after saving.",
primaryActionTitle: "Save and continue",
allowsClearingStoredConfiguration: false
)
.frame(maxWidth: 760)
Text("Production note: this setup flow does not bypass backend TLS failures. If the configured endpoint is unhealthy, Velocity will save the session and report the live refresh error truthfully.")
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.mutedFg)
.multilineTextAlignment(.center)
.frame(maxWidth: 760)
}
.padding(28)
}
}
}
#Preview {
ConfigurationGateView()
}

View File

@@ -3,6 +3,8 @@ import SwiftUI
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"
@@ -10,9 +12,20 @@ enum AppSection: String, CaseIterable, Hashable, Identifiable {
case inventory = "Inventory"
case settings = "Settings"
var displayTitle: String {
switch self {
case .sentinel:
return SentinelScope.navigationTitle
default:
return rawValue
}
}
var systemImage: String {
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"
@@ -25,6 +38,8 @@ enum AppSection: String, CaseIterable, Hashable, Identifiable {
var accentColor: Color {
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
@@ -37,14 +52,21 @@ enum AppSection: String, CaseIterable, Hashable, Identifiable {
struct ContentView: View {
@State private var selectedSection: AppSection? = .dashboard
@State private var session = SessionStore.shared
var body: some View {
Group {
if session.isConfigured {
NavigationSplitView(columnVisibility: .constant(.all)) {
sidebarContent
} detail: {
detailContent
}
.navigationSplitViewStyle(.balanced)
} else {
ConfigurationGateView()
}
}
}
// MARK: Sidebar
@@ -84,9 +106,14 @@ struct ContentView: View {
// Nav items
VStack(spacing: 2) {
ForEach(AppSection.allCases) { section in
SidebarRow(section: section,
isSelected: selectedSection == section)
.onTapGesture { selectedSection = section }
Button {
selectedSection = section
} label: {
SidebarRow(section: section, isSelected: selectedSection == section)
}
.buttonStyle(.plain)
.accessibilityLabel(section.displayTitle)
.accessibilityAddTraits(selectedSection == section ? [.isSelected] : [])
}
}
.padding(.horizontal, 8)
@@ -109,7 +136,7 @@ struct ContentView: View {
Text(operatorName)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(VelocityTheme.foreground)
Text(AppConfig.authModeDescription)
Text(session.authModeDescription)
.font(.system(size: 10))
.foregroundStyle(VelocityTheme.mutedFg)
}
@@ -130,6 +157,8 @@ struct ContentView: View {
Group {
switch selectedSection {
case .dashboard: DashboardView()
case .clients: ClientsView()
case .imports: ImportsView()
case .communications: CommunicationsView()
case .calendar: CalendarView()
case .oracle: OracleView()
@@ -144,11 +173,11 @@ struct ContentView: View {
}
private var operatorName: String {
AppConfig.apiEmail ?? "Velocity Operator"
session.operatorIdentity
}
private var operatorInitials: String {
let source = AppConfig.apiEmail ?? "VO"
let source = session.operatorIdentity
let parts = source
.replacingOccurrences(of: "@", with: " ")
.split(separator: ".")
@@ -170,7 +199,7 @@ private struct SidebarRow: View {
.foregroundStyle(isSelected ? section.accentColor : VelocityTheme.mutedFg)
.frame(width: 20)
Text(section.rawValue)
Text(section.displayTitle)
.font(.system(size: 14, weight: isSelected ? .semibold : .regular))
.foregroundStyle(isSelected ? VelocityTheme.foreground : VelocityTheme.mutedFg)

View File

@@ -0,0 +1,302 @@
import Foundation
import Security
/// Central app configuration.
/// Build settings remain the fallback, but production installs should prefer
/// 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"
private static let runtimeDreamWeaverAPIKeyKey = "velocity.runtime.dream_weaver_api_key"
private static let runtimeEmailKey = "velocity.runtime.email"
private static let runtimePasswordKey = "velocity.runtime.password"
private static let runtimeBearerTokenKey = "velocity.runtime.bearer_token"
private static let runtimeAccessTokenKey = "velocity.runtime.access_token"
private static let runtimeAccessTokenExpiresAtKey = "velocity.runtime.access_token_expires_at"
private static let keychainService = "com.desineuron.velocity.ipad.session"
static func parsedValue(from infoDictionary: [String: Any]?, key: String) -> String? {
let raw = infoDictionary?[key] as? String
return sanitizedValue(raw, key: key)
}
static func isLiveConfigured(
bearerToken: String?,
email: String?,
password: String?
) -> Bool {
bearerToken != nil || (email != nil && password != nil)
}
static func authModeDescription(
bearerToken: String?,
email: String?,
password: String?
) -> String {
if bearerToken != nil {
return "Bearer token"
}
if email != nil && password != nil {
return "Email/password"
}
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
}
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty || trimmed == "$(\(key))" {
return nil
}
return trimmed
}
/// Base URL for the Velocity backend / gateway.
static var baseURL: String {
runtimeBaseURL ?? value(for: "BASE_URL") ?? "https://velocity.desineuron.in/api"
}
/// Dedicated Dream Weaver gateway endpoint when configured; otherwise
/// generation falls back to the main backend endpoint.
static var dreamWeaverBaseURL: String {
configuredDreamWeaverBaseURL ?? originBaseURL(from: baseURL)
}
static var usesDedicatedDreamWeaverBaseURL: Bool {
guard let configuredDreamWeaverBaseURL else {
return false
}
return configuredDreamWeaverBaseURL != baseURL
}
static var dreamWeaverAPIKey: String? {
runtimeDreamWeaverAPIKey ?? value(for: "DREAM_WEAVER_API_KEY")
}
static var apiEmail: String? {
runtimeEmail ?? value(for: "API_EMAIL")
}
static var apiPassword: String? {
runtimePassword ?? value(for: "API_PASSWORD")
}
static var apiBearerToken: String? {
runtimeBearerToken ?? value(for: "API_BEARER_TOKEN")
}
static var apiAccessToken: String? {
guard let expiresAt = runtimeAccessTokenExpiresAt, expiresAt > Date().addingTimeInterval(60) else {
try? clearStoredAccessToken()
return nil
}
return secret(account: runtimeAccessTokenKey)
}
static var isLiveConfigured: Bool {
isLiveConfigured(
bearerToken: apiBearerToken,
email: apiEmail,
password: apiPassword
)
}
static var authModeDescription: String {
authModeDescription(
bearerToken: apiBearerToken,
email: apiEmail,
password: apiPassword
)
}
static var hasStoredRuntimeConfiguration: Bool {
runtimeBaseURL != nil ||
runtimeDreamWeaverBaseURL != nil ||
runtimeEmail != nil ||
runtimePassword != nil ||
runtimeBearerToken != nil
}
static func currentSessionConfiguration() -> AppSessionConfiguration {
AppSessionConfiguration(
baseURL: baseURL,
dreamWeaverBaseURL: dreamWeaverBaseURL,
usesDedicatedDreamWeaverBaseURL: usesDedicatedDreamWeaverBaseURL,
hasDreamWeaverAPIKey: dreamWeaverAPIKey != nil,
email: apiEmail,
hasPassword: apiPassword != nil,
hasBearerToken: apiBearerToken != nil,
source: hasStoredRuntimeConfiguration ? .secureDeviceStorage : .buildConfiguration
)
}
static func saveRuntimeConfiguration(
baseURL: String,
dreamWeaverBaseURL: String?,
dreamWeaverAPIKey: String?,
email: String?,
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(dreamWeaverAPIKey, account: runtimeDreamWeaverAPIKeyKey)
try storeSecret(password, account: runtimePasswordKey)
try storeSecret(bearerToken, account: runtimeBearerTokenKey)
try clearStoredAccessToken()
}
static func clearStoredRuntimeConfiguration() throws {
UserDefaults.standard.removeObject(forKey: runtimeBaseURLKey)
UserDefaults.standard.removeObject(forKey: runtimeDreamWeaverBaseURLKey)
UserDefaults.standard.removeObject(forKey: runtimeEmailKey)
try deleteSecret(account: runtimeDreamWeaverAPIKeyKey)
try deleteSecret(account: runtimePasswordKey)
try deleteSecret(account: runtimeBearerTokenKey)
try clearStoredAccessToken()
}
static func saveAccessToken(_ token: String, expiresIn: Int?) throws {
try storeSecret(token, account: runtimeAccessTokenKey)
let lifetime = TimeInterval(max(expiresIn ?? 28_800, 60))
UserDefaults.standard.set(Date().addingTimeInterval(lifetime).timeIntervalSince1970, forKey: runtimeAccessTokenExpiresAtKey)
}
static func clearStoredAccessToken() throws {
UserDefaults.standard.removeObject(forKey: runtimeAccessTokenExpiresAtKey)
try deleteSecret(account: runtimeAccessTokenKey)
}
private static var runtimeBaseURL: String? {
sanitizedValue(UserDefaults.standard.string(forKey: runtimeBaseURLKey), key: runtimeBaseURLKey)
}
private static var configuredDreamWeaverBaseURL: String? {
runtimeDreamWeaverBaseURL ?? value(for: "DREAM_WEAVER_BASE_URL")
}
private static var runtimeDreamWeaverBaseURL: String? {
sanitizedValue(
UserDefaults.standard.string(forKey: runtimeDreamWeaverBaseURLKey),
key: runtimeDreamWeaverBaseURLKey
)
}
private static var runtimeDreamWeaverAPIKey: String? {
secret(account: runtimeDreamWeaverAPIKeyKey)
}
private static var runtimeEmail: String? {
sanitizedValue(UserDefaults.standard.string(forKey: runtimeEmailKey), key: runtimeEmailKey)
}
private static var runtimePassword: String? {
secret(account: runtimePasswordKey)
}
private static var runtimeBearerToken: String? {
secret(account: runtimeBearerTokenKey)
}
private static var runtimeAccessTokenExpiresAt: Date? {
let rawValue = UserDefaults.standard.double(forKey: runtimeAccessTokenExpiresAtKey)
guard rawValue > 0 else {
return nil
}
return Date(timeIntervalSince1970: rawValue)
}
private static func originBaseURL(from rawValue: String) -> String {
guard var components = URLComponents(string: rawValue) else {
return rawValue
}
components.path = ""
components.query = nil
components.fragment = nil
return components.string ?? rawValue
}
private static func secret(account: String) -> String? {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: keychainService,
kSecAttrAccount: account,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess else {
return nil
}
guard let data = result as? Data else {
return nil
}
return String(data: data, encoding: .utf8)
}
private static func storeSecret(_ value: String?, account: String) throws {
if let value, let data = value.data(using: .utf8) {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: keychainService,
kSecAttrAccount: account
]
let attributes: [CFString: Any] = [
kSecValueData: data
]
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
if status == errSecSuccess {
return
}
if status == errSecItemNotFound {
var addQuery = query
addQuery[kSecValueData] = data
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
guard addStatus == errSecSuccess else {
throw SessionPersistenceError.keychainWriteFailed(addStatus)
}
return
}
throw SessionPersistenceError.keychainWriteFailed(status)
}
try deleteSecret(account: account)
}
private static func deleteSecret(account: String) throws {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: keychainService,
kSecAttrAccount: account
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw SessionPersistenceError.keychainDeleteFailed(status)
}
}
}

View File

@@ -0,0 +1,247 @@
import Foundation
enum SessionAuthMode: String, CaseIterable, Identifiable {
case emailPassword = "Email/password"
case bearerToken = "Bearer token"
var id: String { rawValue }
}
enum SessionConfigurationSource: String {
case buildConfiguration = "Build configuration"
case secureDeviceStorage = "Secure device storage"
}
struct AppSessionConfiguration: Equatable {
let baseURL: String
let dreamWeaverBaseURL: String
let usesDedicatedDreamWeaverBaseURL: Bool
let hasDreamWeaverAPIKey: Bool
let email: String?
let hasPassword: Bool
let hasBearerToken: Bool
let source: SessionConfigurationSource
var authMode: SessionAuthMode {
hasBearerToken ? .bearerToken : .emailPassword
}
var isConfigured: Bool {
hasBearerToken || (email != nil && hasPassword)
}
var authModeDescription: String {
if hasBearerToken {
return "Bearer token"
}
if email != nil && hasPassword {
return "Email/password"
}
return "Credentials required"
}
var operatorIdentity: String {
if let email, !email.isEmpty {
return email
}
if hasBearerToken {
return "Token authenticated operator"
}
return "Unconfigured operator"
}
var dreamWeaverEndpointModeDescription: String {
usesDedicatedDreamWeaverBaseURL ? "Dedicated gateway" : "Shared with backend"
}
var dreamWeaverAuthenticationDescription: String {
hasDreamWeaverAPIKey ? "API key configured" : "No gateway key configured"
}
}
struct SessionConfigurationDraft: Equatable {
var baseURL: String
var dreamWeaverBaseURL: String
var dreamWeaverAPIKey: String
var authMode: SessionAuthMode
var email: String
var password: String
var bearerToken: String
var existingDreamWeaverAPIKeyAvailable: Bool
var existingPasswordAvailable: Bool
var existingBearerTokenAvailable: Bool
var baselineEmail: String?
var trimmedBaseURL: String? {
Self.trimmedValue(baseURL)
}
var trimmedEmail: String? {
Self.trimmedValue(email)
}
var trimmedPassword: String? {
Self.trimmedValue(password)
}
var trimmedBearerToken: String? {
Self.trimmedValue(bearerToken)
}
var normalizedBaseURL: String? {
Self.normalizedHTTPSOrigin(from: trimmedBaseURL)
}
var trimmedDreamWeaverBaseURL: String? {
Self.trimmedValue(dreamWeaverBaseURL)
}
var normalizedDreamWeaverBaseURL: String? {
Self.normalizedHTTPSOrigin(from: trimmedDreamWeaverBaseURL)
}
var trimmedDreamWeaverAPIKey: String? {
Self.trimmedValue(dreamWeaverAPIKey)
}
func validationErrors() -> [String] {
var errors: [String] = []
guard let trimmedBaseURL else {
errors.append("Backend endpoint is required.")
return errors
}
guard URLComponents(string: trimmedBaseURL) != nil else {
errors.append("Backend endpoint must be a valid URL.")
return errors
}
guard normalizedBaseURL != nil else {
errors.append("Backend endpoint must be an HTTPS API base like https://velocity.desineuron.in/api.")
return errors
}
if let trimmedDreamWeaverBaseURL {
guard URLComponents(string: trimmedDreamWeaverBaseURL) != nil else {
errors.append("Dream Weaver endpoint must be a valid URL.")
return errors
}
guard normalizedDreamWeaverBaseURL != nil else {
errors.append("Dream Weaver endpoint must be an HTTPS origin like https://dreamweaver.desineuron.in.")
return errors
}
}
switch authMode {
case .emailPassword:
guard let trimmedEmail else {
errors.append("Operator email is required for email/password login.")
break
}
guard trimmedEmail.contains("@"), trimmedEmail.contains(".") else {
errors.append("Operator email must look like a valid email address.")
break
}
if trimmedPassword == nil &&
!(existingPasswordAvailable && trimmedEmail.caseInsensitiveCompare(baselineEmail ?? "") == .orderedSame) {
errors.append("Password is required for email/password login.")
}
case .bearerToken:
if trimmedBearerToken == nil && !existingBearerTokenAvailable {
errors.append("Bearer token is required when token auth is selected.")
}
}
return errors
}
func resolvedEmail(existingEmail: String?) -> String? {
guard authMode == .emailPassword else { return nil }
return trimmedEmail ?? existingEmail
}
func resolvedPassword(existingPassword: String?) -> String? {
guard authMode == .emailPassword else { return nil }
if let trimmedPassword {
return trimmedPassword
}
guard existingPasswordAvailable else {
return nil
}
guard trimmedEmail?.caseInsensitiveCompare(baselineEmail ?? "") == .orderedSame else {
return nil
}
return existingPassword
}
func resolvedBearerToken(existingToken: String?) -> String? {
guard authMode == .bearerToken else { return nil }
return trimmedBearerToken ?? (existingBearerTokenAvailable ? existingToken : nil)
}
func resolvedDreamWeaverBaseURL(normalizedBaseURL: String) -> String? {
normalizedDreamWeaverBaseURL
}
func resolvedDreamWeaverAPIKey(existingKey: String?) -> String? {
trimmedDreamWeaverAPIKey ?? (existingDreamWeaverAPIKeyAvailable ? existingKey : nil)
}
private static func trimmedValue(_ value: String?) -> String? {
guard let value else {
return nil
}
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
private static func normalizedHTTPSOrigin(from raw: String?) -> String? {
guard let raw else {
return nil
}
guard var components = URLComponents(string: raw) else {
return nil
}
guard let scheme = components.scheme?.lowercased(),
let host = components.host?.lowercased() else {
return nil
}
guard scheme == "https" else {
return nil
}
guard components.query == nil, components.fragment == nil else {
return nil
}
let path = components.path.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedPath = path == "/api/" ? "/api" : path
guard normalizedPath.isEmpty || normalizedPath == "/" || normalizedPath == "/api" else {
return nil
}
components.scheme = scheme
components.host = host
components.path = normalizedPath == "/api" ? "/api" : ""
components.query = nil
components.fragment = nil
return components.string
}
}
enum SessionPersistenceError: LocalizedError {
case keychainWriteFailed(OSStatus)
case keychainDeleteFailed(OSStatus)
var errorDescription: String? {
switch self {
case .keychainWriteFailed(let status):
return "Velocity could not save credentials securely on this iPad. Keychain status: \(status)."
case .keychainDeleteFailed(let status):
return "Velocity could not clear stored credentials from this iPad. Keychain status: \(status)."
}
}
}

View File

@@ -0,0 +1,204 @@
import Foundation
import Observation
@MainActor
@Observable
final class SessionStore {
static let shared = SessionStore()
private init() {
reloadFromPersistedConfiguration()
}
var currentConfiguration = AppConfig.currentSessionConfiguration()
var draftBaseURL = ""
var draftDreamWeaverBaseURL = ""
var draftDreamWeaverAPIKey = ""
var draftAuthMode: SessionAuthMode = .emailPassword
var draftEmail = ""
var draftPassword = ""
var draftBearerToken = ""
var isSaving = false
var statusMessage: String?
var errorMessage: String?
private var existingPasswordAvailable = false
private var existingBearerTokenAvailable = false
private var existingDreamWeaverAPIKeyAvailable = false
private var baselineEmail: String?
var isConfigured: Bool {
currentConfiguration.isConfigured
}
var authModeDescription: String {
currentConfiguration.authModeDescription
}
var operatorIdentity: String {
currentConfiguration.operatorIdentity
}
var endpointDisplay: String {
currentConfiguration.baseURL
}
var dreamWeaverEndpointDisplay: String {
currentConfiguration.dreamWeaverBaseURL
}
var dreamWeaverEndpointModeDescription: String {
currentConfiguration.dreamWeaverEndpointModeDescription
}
var dreamWeaverAuthenticationDescription: String {
currentConfiguration.dreamWeaverAuthenticationDescription
}
var configurationSourceDescription: String {
currentConfiguration.source.rawValue
}
var isUsingStoredRuntimeConfiguration: Bool {
currentConfiguration.source == .secureDeviceStorage
}
var hasUnsavedChanges: Bool {
draftBaseURL != currentConfiguration.baseURL ||
draftDreamWeaverBaseURL != persistedDreamWeaverDraftValue ||
!draftDreamWeaverAPIKey.isEmpty ||
draftAuthMode != currentConfiguration.authMode ||
draftEmail != (currentConfiguration.email ?? "") ||
!draftPassword.isEmpty ||
!draftBearerToken.isEmpty
}
func reloadFromPersistedConfiguration() {
currentConfiguration = AppConfig.currentSessionConfiguration()
draftBaseURL = currentConfiguration.baseURL
draftDreamWeaverBaseURL = persistedDreamWeaverDraftValue
draftDreamWeaverAPIKey = ""
draftAuthMode = currentConfiguration.authMode
draftEmail = currentConfiguration.email ?? ""
draftPassword = ""
draftBearerToken = ""
existingDreamWeaverAPIKeyAvailable = currentConfiguration.hasDreamWeaverAPIKey
existingPasswordAvailable = currentConfiguration.hasPassword
existingBearerTokenAvailable = currentConfiguration.hasBearerToken
baselineEmail = currentConfiguration.email
}
func discardDraftChanges() {
errorMessage = nil
statusMessage = nil
reloadFromPersistedConfiguration()
}
func saveDraft() async {
errorMessage = nil
statusMessage = nil
let draft = SessionConfigurationDraft(
baseURL: draftBaseURL,
dreamWeaverBaseURL: draftDreamWeaverBaseURL,
dreamWeaverAPIKey: draftDreamWeaverAPIKey,
authMode: draftAuthMode,
email: draftEmail,
password: draftPassword,
bearerToken: draftBearerToken,
existingDreamWeaverAPIKeyAvailable: existingDreamWeaverAPIKeyAvailable,
existingPasswordAvailable: existingPasswordAvailable,
existingBearerTokenAvailable: existingBearerTokenAvailable,
baselineEmail: baselineEmail
)
let errors = draft.validationErrors()
guard errors.isEmpty else {
errorMessage = errors.joined(separator: " ")
return
}
guard let normalizedBaseURL = draft.normalizedBaseURL else {
errorMessage = "Backend endpoint must be a valid HTTPS origin."
return
}
isSaving = true
do {
try AppConfig.saveRuntimeConfiguration(
baseURL: normalizedBaseURL,
dreamWeaverBaseURL: draft.resolvedDreamWeaverBaseURL(normalizedBaseURL: normalizedBaseURL),
dreamWeaverAPIKey: draft.resolvedDreamWeaverAPIKey(existingKey: AppConfig.dreamWeaverAPIKey),
email: draft.resolvedEmail(existingEmail: currentConfiguration.email),
password: draft.resolvedPassword(existingPassword: AppConfig.apiPassword),
bearerToken: draft.resolvedBearerToken(existingToken: AppConfig.apiBearerToken)
)
await VelocityAPIClient.shared.resetSession()
AppStore.shared.resetLiveData()
reloadFromPersistedConfiguration()
await AppStore.shared.refresh()
let dreamWeaverHealthy = await ComfyClient.shared.checkHealth()
statusMessage = verificationStatusMessage(
successPrefix: "Configuration saved.",
backendRefreshError: AppStore.shared.errorMessage,
dreamWeaverHealthy: dreamWeaverHealthy
)
} catch {
errorMessage = error.localizedDescription
}
isSaving = false
}
func clearStoredConfiguration() async {
errorMessage = nil
statusMessage = nil
isSaving = true
do {
try AppConfig.clearStoredRuntimeConfiguration()
await VelocityAPIClient.shared.resetSession()
AppStore.shared.resetLiveData()
reloadFromPersistedConfiguration()
if currentConfiguration.isConfigured {
await AppStore.shared.refresh()
let dreamWeaverHealthy = await ComfyClient.shared.checkHealth()
statusMessage = verificationStatusMessage(
successPrefix: "Stored override cleared. Velocity is now using the build configuration.",
backendRefreshError: AppStore.shared.errorMessage,
dreamWeaverHealthy: dreamWeaverHealthy
)
} else {
statusMessage = "Stored session cleared. This iPad now requires runtime configuration before live data can load."
}
} catch {
errorMessage = error.localizedDescription
}
isSaving = false
}
private var persistedDreamWeaverDraftValue: String {
currentConfiguration.usesDedicatedDreamWeaverBaseURL ? currentConfiguration.dreamWeaverBaseURL : ""
}
private func verificationStatusMessage(
successPrefix: String,
backendRefreshError: String?,
dreamWeaverHealthy: Bool
) -> String {
switch (backendRefreshError, dreamWeaverHealthy) {
case (nil, true):
return "\(successPrefix) Core backend refresh and Dream Weaver gateway probe both succeeded."
case (let backendRefreshError?, true):
return "\(successPrefix) Core backend refresh failed: \(backendRefreshError) Dream Weaver gateway probe succeeded."
case (nil, false):
return "\(successPrefix) Core backend refresh succeeded, but the Dream Weaver gateway probe failed. Verify the dedicated generation endpoint and routing."
case (let backendRefreshError?, false):
return "\(successPrefix) Core backend refresh failed: \(backendRefreshError) Dream Weaver gateway probe also failed."
}
}
}

View File

@@ -4,27 +4,41 @@ import UIKit
// MARK: - ComfyClient
/// Handles all Dream Weaver API communication.
/// The iPad app talks ONLY to the gateway (port 8080), never directly to ComfyUI.
/// The iPad app talks only to the configured Dream Weaver gateway, never directly to ComfyUI.
/// Flow: POST /dream-weaver poll /status GET /result
final class ComfyClient {
static let shared = ComfyClient()
private var baseURL: String { AppConfig.baseURL }
private init() {}
private let urlSession: URLSession
private var baseURL: String { AppConfig.dreamWeaverBaseURL }
private var apiKey: String? { AppConfig.dreamWeaverAPIKey }
init(urlSession: URLSession = .shared) {
self.urlSession = urlSession
}
// MARK: - Health Check
/// Call on app launch to confirm gateway is reachable.
/// Returns `true` if `{ "status": "ok" }`.
/// 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 {
guard let url = URL(string: "\(baseURL)/health") else { return false }
var request = URLRequest(url: url)
do {
var request = authorizedRequest(url: try resolvedURL(candidate: nil, fallbackPath: "/health"))
request.timeoutInterval = 30.0
guard let (data, _) = try? await URLSession.shared.data(for: request),
let json = try? JSONDecoder().decode(HealthResponse.self, from: data) else {
let (data, response) = try await urlSession.data(for: request)
guard let http = response as? HTTPURLResponse, 200..<300 ~= http.statusCode else {
return false
}
let json = try JSONDecoder().decode(HealthResponse.self, from: data)
guard ["ok", "healthy"].contains(json.status.lowercased()) else {
return false
}
return try await probeDreamWeaverRoute()
} catch {
return false
}
// Server returns "healthy" (v2.0-FINAL gateway) accept both variants
return json.status == "ok" || json.status == "healthy"
}
// MARK: - Main Generation Pipeline
@@ -45,7 +59,7 @@ final class ComfyClient {
let job = try await submitJob(imageData: imageData, roomType: roomType, keywords: keywords)
// 2. Poll status every 2s until ready (max 5 min per integration guide §3.3)
let resultURL = try await pollUntilReady(jobId: job.jobId)
let resultURL = try await pollUntilReady(job: job)
// 3. Download result PNG
return try await downloadResult(from: resultURL)
@@ -54,12 +68,8 @@ final class ComfyClient {
// MARK: - Step 1: POST /dream-weaver
private func submitJob(imageData: Data, roomType: String, keywords: String) async throws -> GenerationJob {
guard let url = URL(string: "\(baseURL)/dream-weaver") else {
throw DreamWeaverError.generationFailed("Invalid gateway URL")
}
let boundary = "Boundary-\(UUID().uuidString)"
var request = URLRequest(url: url)
var request = authorizedRequest(url: try resolvedURL(candidate: nil, fallbackPath: "/dream-weaver"))
request.httpMethod = "POST"
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
request.timeoutInterval = 180.0
@@ -70,7 +80,7 @@ final class ComfyClient {
boundary: boundary
)
let (data, response) = try await URLSession.shared.data(for: request)
let (data, response) = try await urlSession.data(for: request)
guard let http = response as? HTTPURLResponse, 200..<300 ~= http.statusCode else {
let code = (response as? HTTPURLResponse)?.statusCode ?? -1
let detail = String(data: data, encoding: .utf8) ?? ""
@@ -83,18 +93,28 @@ final class ComfyClient {
// MARK: - Step 2: GET /dream-weaver/status/{job_id}
/// Polls every 2s, max 150 attempts (5 minutes). Returns full result URL when ready.
private func pollUntilReady(jobId: String, maxAttempts: Int = 150) async throws -> URL {
let statusURL = URL(string: "\(baseURL)/dream-weaver/status/\(jobId)")!
private func pollUntilReady(job: GenerationJob, maxAttempts: Int = 150) async throws -> URL {
let statusURL = try job.resolvedPollURL(baseURL: baseURL)
for _ in 0..<maxAttempts {
try await Task.sleep(nanoseconds: 2_000_000_000) // 2s
let (data, _) = try await URLSession.shared.data(from: statusURL)
let (data, response) = try await urlSession.data(for: authorizedRequest(url: statusURL))
guard let http = response as? HTTPURLResponse else {
throw DreamWeaverError.generationFailed("Dream Weaver status check returned no HTTP response.")
}
guard 200..<300 ~= http.statusCode else {
let detail = String(data: data, encoding: .utf8) ?? ""
throw DreamWeaverError.generationFailed(
"Dream Weaver status check failed (HTTP \(http.statusCode))\(detail.isEmpty ? "" : ": \(detail)")"
)
}
let status = try JSONDecoder().decode(JobStatus.self, from: data)
if status.ready {
return URL(string: "\(baseURL)/dream-weaver/result/\(jobId)")!
return try status.resolvedResultURL(baseURL: baseURL, jobId: job.jobId)
}
if status.status == "error" {
if status.status.lowercased() == "error" {
throw DreamWeaverError.generationFailed(status.error ?? "Unknown server error")
}
}
@@ -104,7 +124,14 @@ final class ComfyClient {
// MARK: - Step 3: GET /dream-weaver/result/{job_id}
private func downloadResult(from url: URL) async throws -> UIImage {
let (data, _) = try await URLSession.shared.data(from: url)
let (data, response) = try await urlSession.data(for: authorizedRequest(url: url, accept: "image/png"))
guard let http = response as? HTTPURLResponse, 200..<300 ~= http.statusCode else {
let code = (response as? HTTPURLResponse)?.statusCode ?? -1
let detail = String(data: data, encoding: .utf8) ?? ""
throw DreamWeaverError.generationFailed(
"Dream Weaver result download failed (HTTP \(code))\(detail.isEmpty ? "" : ": \(detail)")"
)
}
guard let image = UIImage(data: data) else {
throw DreamWeaverError.invalidImageData
}
@@ -141,6 +168,63 @@ final class ComfyClient {
body += "--\(boundary)--\(crlf)"
return body
}
private func probeDreamWeaverRoute() async throws -> Bool {
let probeURL = try resolvedURL(
candidate: nil,
fallbackPath: "/dream-weaver/status/velocity-route-probe"
)
var request = authorizedRequest(url: probeURL)
request.timeoutInterval = 30.0
let (data, response) = try await urlSession.data(for: request)
guard let http = response as? HTTPURLResponse else {
return false
}
switch http.statusCode {
case 200..<300:
return (try? JSONDecoder().decode(JobStatus.self, from: data)) != nil
case 404:
guard let errorResponse = try? JSONDecoder().decode(DreamWeaverErrorResponse.self, from: data) else {
return false
}
return errorResponse.detail.localizedCaseInsensitiveContains("job not found")
default:
return false
}
}
private func resolvedURL(candidate: String?, fallbackPath: String) throws -> URL {
let gatewayBaseURL = baseURL.trimmingCharacters(in: .whitespacesAndNewlines)
guard let base = URL(string: gatewayBaseURL) else {
throw DreamWeaverError.generationFailed("Invalid Dream Weaver gateway URL: \(gatewayBaseURL)")
}
let value = candidate?.trimmingCharacters(in: .whitespacesAndNewlines)
if let value, !value.isEmpty {
if let absolute = URL(string: value), absolute.scheme != nil {
return absolute
}
if let relative = URL(string: value, relativeTo: base)?.absoluteURL {
return relative
}
}
guard let fallback = URL(string: fallbackPath, relativeTo: base)?.absoluteURL else {
throw DreamWeaverError.generationFailed("Invalid Dream Weaver route: \(fallbackPath)")
}
return fallback
}
private func authorizedRequest(url: URL, accept: String = "application/json") -> URLRequest {
var request = URLRequest(url: url)
request.setValue(accept, forHTTPHeaderField: "Accept")
if let apiKey, !apiKey.isEmpty {
request.setValue(apiKey, forHTTPHeaderField: "X-Dream-Weaver-API-Key")
}
return request
}
}
// MARK: - Data Models (§5 of integration guide)
@@ -148,14 +232,22 @@ final class ComfyClient {
struct GenerationJob: Codable {
let jobId: String
let status: String
let pollUrl: String
let resultUrl: String
let pollUrl: String?
let resultUrl: String?
enum CodingKeys: String, CodingKey {
case jobId = "job_id"
case status
case pollUrl = "poll_url"
case resultUrl = "result_url"
}
func resolvedPollURL(baseURL: String) throws -> URL {
try resolvedURL(candidate: pollUrl, baseURL: baseURL, fallbackPath: "/dream-weaver/status/\(jobId)")
}
func resolvedResultURL(baseURL: String) throws -> URL {
try resolvedURL(candidate: resultUrl, baseURL: baseURL, fallbackPath: "/dream-weaver/result/\(jobId)")
}
}
struct JobStatus: Codable {
@@ -168,6 +260,10 @@ struct JobStatus: Codable {
case resultUrl = "result_url"
case error
}
func resolvedResultURL(baseURL: String, jobId: String) throws -> URL {
try resolvedURL(candidate: resultUrl, baseURL: baseURL, fallbackPath: "/dream-weaver/result/\(jobId)")
}
}
struct HealthResponse: Codable {
@@ -175,6 +271,10 @@ struct HealthResponse: Codable {
let comfyui: Bool?
}
struct DreamWeaverErrorResponse: Codable {
let detail: String
}
// MARK: - Errors
enum DreamWeaverError: LocalizedError {
@@ -193,6 +293,28 @@ enum DreamWeaverError: LocalizedError {
}
}
private func resolvedURL(candidate: String?, baseURL: String, fallbackPath: String) throws -> URL {
let gatewayBaseURL = baseURL.trimmingCharacters(in: .whitespacesAndNewlines)
guard let base = URL(string: gatewayBaseURL) else {
throw DreamWeaverError.generationFailed("Invalid Dream Weaver gateway URL: \(gatewayBaseURL)")
}
let value = candidate?.trimmingCharacters(in: .whitespacesAndNewlines)
if let value, !value.isEmpty {
if let absolute = URL(string: value), absolute.scheme != nil {
return absolute
}
if let relative = URL(string: value, relativeTo: base)?.absoluteURL {
return relative
}
}
guard let fallback = URL(string: fallbackPath, relativeTo: base)?.absoluteURL else {
throw DreamWeaverError.generationFailed("Invalid Dream Weaver route: \(fallbackPath)")
}
return fallback
}
// MARK: - UIImage Helpers
extension UIImage {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,815 @@
import Foundation
import Observation
struct DashboardMetrics {
let leadCount: Int
let whaleLeadCount: Int
let propertyCount: Int
let todayCalendarCount: Int
let pendingTaskCount: Int
let urgentTaskCount: Int
let pendingInsights: Int
let pendingTranscriptions: Int
}
@MainActor
@Observable
final class AppStore {
static let shared = AppStore()
private static let locallyCreatedCalendarEventsKey = "velocity.calendar.locally_created_events"
private static let locallyMutatedTasksKey = "velocity.calendar.locally_mutated_tasks"
private static let locallyHiddenTaskIDsKey = "velocity.calendar.locally_hidden_task_ids"
private init() {
localTaskOverrides = Self.loadLocallyMutatedTasks()
locallyResolvedTaskIDs = Self.loadLocallyHiddenTaskIDs()
tasks = VelocityTaskDTO.sortedForOperatorReview(Array(localTaskOverrides.values))
locallyCreatedCalendarEvents = Self.loadLocallyCreatedCalendarEvents()
calendarEvents = locallyCreatedCalendarEvents
}
private struct RefreshSnapshot {
let contacts: [VelocityCanonicalContactListItemDTO]
let leads: [VelocityLeadDTO]
let tasks: [VelocityTaskDTO]
let pendingTaskCount: Int
let pendingTaskIDs: Set<String>
let kanbanColumns: [VelocityKanbanColumnDTO]
let opportunities: [VelocityOpportunityDTO]
let properties: [VelocityPropertyDTO]
let calendarEvents: [VelocityCalendarEventDTO]
let alertSnapshot: VelocityAlertSnapshotDTO
let leadEvents: [String: [VelocityCommunicationEventDTO]]
}
private struct CalendarTaskRefresh {
let tasks: [VelocityTaskDTO]
let pendingTaskCount: Int
let pendingTaskIDs: Set<String>
}
private struct PersistedCalendarEvent: Codable {
let calendarEventId: String
let leadId: String?
let title: String
let description: String?
let startAt: String
let endAt: String
let allDay: Bool
let status: String
let reminderMinutes: [Int]
let createdBy: String
let location: String?
let createdAt: String
init(event: VelocityCalendarEventDTO) {
calendarEventId = event.calendarEventId
leadId = event.leadId
title = event.title
description = event.description
startAt = event.startAt
endAt = event.endAt
allDay = event.allDay
status = event.status
reminderMinutes = event.reminderMinutes
createdBy = event.createdBy
location = event.location
createdAt = event.createdAt
}
var event: VelocityCalendarEventDTO {
VelocityCalendarEventDTO(
calendarEventId: calendarEventId,
leadId: leadId,
title: title,
description: description,
startAt: startAt,
endAt: endAt,
allDay: allDay,
status: status,
reminderMinutes: reminderMinutes,
createdBy: createdBy,
location: location,
createdAt: createdAt
)
}
}
private struct PersistedTask: Codable {
let reminderId: String
let reminderType: String
let title: String
let notes: String?
let dueAt: String?
let status: String
let priority: String
let personId: String?
let clientName: String?
let clientPhone: String?
init(task: VelocityTaskDTO) {
reminderId = task.reminderId
reminderType = task.reminderType
title = task.title
notes = task.notes
dueAt = task.dueAt
status = task.status
priority = task.priority
personId = task.personId
clientName = task.clientName
clientPhone = task.clientPhone
}
var task: VelocityTaskDTO {
VelocityTaskDTO(
reminderId: reminderId,
reminderType: reminderType,
title: title,
notes: notes,
dueAt: dueAt,
status: status,
priority: priority,
personId: personId,
clientName: clientName,
clientPhone: clientPhone
)
}
}
var contacts: [VelocityCanonicalContactListItemDTO] = []
var leads: [VelocityLeadDTO] = []
var tasks: [VelocityTaskDTO] = []
var kanbanColumns: [VelocityKanbanColumnDTO] = []
var opportunities: [VelocityOpportunityDTO] = []
var properties: [VelocityPropertyDTO] = []
var calendarEvents: [VelocityCalendarEventDTO] = []
var leadEvents: [String: [VelocityCommunicationEventDTO]] = [:]
var alertSnapshot: VelocityAlertSnapshotDTO?
var pendingTaskMetricCount = 0
var isLoading = false
var errorMessage: String?
var lastRefreshAt: Date?
private var activeRefreshTask: Task<RefreshSnapshot, Error>?
private var canonicalPendingTaskCount = 0
private var canonicalPendingTaskIDs: Set<String> = []
private var locallyResolvedTaskIDs: Set<String> = []
private var localTaskOverrides: [String: VelocityTaskDTO] = [:]
private var locallyCreatedCalendarEvents: [VelocityCalendarEventDTO] = []
var operatorIdentity: String {
if let email = AppConfig.apiEmail, !email.isEmpty {
return email
}
if let token = AppConfig.apiBearerToken, !token.isEmpty {
return "Token authenticated operator"
}
return "Unconfigured operator"
}
var authDescription: String {
if let _ = AppConfig.apiBearerToken {
return "Bearer token"
}
if AppConfig.apiEmail != nil, AppConfig.apiPassword != nil {
return "Email/password login"
}
return "Credentials required"
}
var isConfigured: Bool {
AppConfig.isLiveConfigured
}
var metrics: DashboardMetrics {
DashboardMetrics(
leadCount: leads.count,
whaleLeadCount: leads.filter { $0.score >= 90 || $0.qualification.lowercased() == "whale" }.count,
propertyCount: properties.count,
todayCalendarCount: calendarEvents.filter { $0.startsToday }.count,
pendingTaskCount: pendingTaskMetricCount,
urgentTaskCount: tasks.filter {
$0.status.lowercased() == "pending" && ["urgent", "high"].contains($0.priority.lowercased())
}.count,
pendingInsights: alertSnapshot?.pendingInsights ?? 0,
pendingTranscriptions: alertSnapshot?.pendingTranscriptions ?? 0
)
}
var highlightedLeads: [VelocityLeadDTO] {
Array(leads.sorted(by: { $0.score > $1.score }).prefix(5))
}
var highlightedContacts: [VelocityCanonicalContactListItemDTO] {
Array(contacts.prefix(12))
}
var timelineEvents: [TimelineEvent] {
leadEvents
.flatMap { leadId, events in
events.map { TimelineEvent(leadId: leadId, event: $0, leadName: leadName(for: leadId)) }
}
.sorted(by: { $0.date > $1.date })
}
var prioritizedTasks: [VelocityTaskDTO] {
VelocityTaskDTO.sortedForOperatorReview(tasks)
}
func resetLiveData() {
contacts = []
leads = []
tasks = []
kanbanColumns = []
opportunities = []
properties = []
calendarEvents = []
leadEvents = [:]
alertSnapshot = nil
pendingTaskMetricCount = 0
canonicalPendingTaskCount = 0
canonicalPendingTaskIDs = []
isLoading = false
errorMessage = nil
lastRefreshAt = nil
canonicalPendingTaskCount = 0
canonicalPendingTaskIDs = []
locallyResolvedTaskIDs = []
localTaskOverrides = [:]
locallyCreatedCalendarEvents = []
Self.saveLocallyHiddenTaskIDs([])
Self.saveLocallyMutatedTasks([])
Self.saveLocallyCreatedCalendarEvents([])
}
func refresh(silent: Bool = false) async {
if !silent {
isLoading = true
}
do {
let task = activeRefreshTask ?? makeRefreshTask()
activeRefreshTask = task
let snapshot = try await task.value
activeRefreshTask = nil
contacts = snapshot.contacts
leads = snapshot.leads
tasks = mergedTasks(with: snapshot.tasks)
canonicalPendingTaskCount = snapshot.pendingTaskCount
canonicalPendingTaskIDs = snapshot.pendingTaskIDs
kanbanColumns = snapshot.kanbanColumns
opportunities = snapshot.opportunities
properties = snapshot.properties
calendarEvents = mergedCalendarEvents(with: snapshot.calendarEvents)
refreshPendingTaskMetricCount()
alertSnapshot = snapshot.alertSnapshot
leadEvents = snapshot.leadEvents
lastRefreshAt = Date()
errorMessage = nil
isLoading = false
} catch {
activeRefreshTask = nil
if !silent || lastRefreshAt == nil {
errorMessage = error.localizedDescription
}
if !silent {
contacts = []
leads = []
tasks = []
kanbanColumns = []
opportunities = []
properties = []
calendarEvents = []
alertSnapshot = nil
pendingTaskMetricCount = 0
canonicalPendingTaskCount = 0
canonicalPendingTaskIDs = []
leadEvents = [:]
}
isLoading = false
}
}
func leadName(for leadId: String) -> String {
leads.first(where: { $0.id == leadId })?.name ?? "Unknown lead"
}
func updateTaskStatus(
reminderId: String,
status: String,
dueAt: String? = nil,
notes: String? = nil
) async throws -> VelocityTaskDTO {
let serverTask: VelocityTaskDTO
do {
serverTask = try await VelocityAPIClient.shared.updateTask(
reminderId: reminderId,
status: status,
dueAt: dueAt,
notes: notes
)
} catch let error as VelocityAPIError where error.statusCode == 404 {
serverTask = locallyResolveMissingTask(
reminderId: reminderId,
status: status,
dueAt: dueAt
)
}
let updatedTask = locallyMutatedTask(from: serverTask, status: status, dueAt: dueAt)
if updatedTask.status.lowercased() == "cancelled" {
localTaskOverrides.removeValue(forKey: reminderId)
locallyResolvedTaskIDs.insert(reminderId)
Self.saveLocallyMutatedTasks(Array(localTaskOverrides.values))
Self.saveLocallyHiddenTaskIDs(Array(locallyResolvedTaskIDs))
tasks.removeAll { $0.reminderId == reminderId }
} else {
locallyResolvedTaskIDs.remove(reminderId)
upsertLocalTaskOverride(updatedTask)
if let index = tasks.firstIndex(where: { $0.reminderId == reminderId }) {
tasks[index] = updatedTask
} else {
tasks.append(updatedTask)
}
tasks = VelocityTaskDTO.sortedForOperatorReview(tasks)
}
refreshPendingTaskMetricCount()
errorMessage = nil
await refresh(silent: true)
return updatedTask
}
func createCalendarEvent(
leadId: String?,
title: String,
description: String?,
startAt: String,
endAt: String,
allDay: Bool,
status: String,
reminderMinutes: [Int],
location: String?,
metadata: [String: String] = [:]
) async throws -> VelocityCalendarEventCreateResultDTO {
let createdEvent: VelocityCalendarEventCreateResultDTO
var shouldPersistLocalFallback = false
do {
createdEvent = try await VelocityAPIClient.shared.createCalendarEvent(
leadId: leadId,
title: title,
description: description,
startAt: startAt,
endAt: endAt,
allDay: allDay,
status: status,
reminderMinutes: reminderMinutes,
location: location,
metadata: metadata
)
} catch let error as VelocityAPIError where error.isRecoverableCalendarCreateFailure {
createdEvent = VelocityCalendarEventCreateResultDTO(
calendarEventId: "local-\(UUID().uuidString)",
createdAt: ISO8601DateFormatter().string(from: Date())
)
shouldPersistLocalFallback = true
}
let optimisticEvent = VelocityCalendarEventDTO(
calendarEventId: createdEvent.calendarEventId,
leadId: leadId,
title: title,
description: description,
startAt: startAt,
endAt: endAt,
allDay: allDay,
status: status,
reminderMinutes: reminderMinutes,
createdBy: "user",
location: location,
createdAt: createdEvent.createdAt
)
upsertLocalCalendarEvent(optimisticEvent, persist: shouldPersistLocalFallback)
calendarEvents = mergedCalendarEvents(with: calendarEvents)
refreshPendingTaskMetricCount()
errorMessage = nil
await refresh(silent: true)
return createdEvent
}
func updateCalendarEvent(
_ event: VelocityCalendarEventDTO,
status: String? = nil,
startAt: String? = nil,
endAt: String? = nil
) async throws -> VelocityCalendarEventDTO {
let shouldPersistFallback: Bool
do {
try await VelocityAPIClient.shared.updateCalendarEvent(
calendarEventId: event.calendarEventId,
startAt: startAt,
endAt: endAt,
status: status
)
shouldPersistFallback = event.calendarEventId.hasPrefix("local-")
} catch let error as VelocityAPIError where error.isRecoverableCalendarCreateFailure {
shouldPersistFallback = true
}
let updatedEvent = VelocityCalendarEventDTO(
calendarEventId: event.calendarEventId,
leadId: event.leadId,
title: event.title,
description: event.description,
startAt: startAt ?? event.startAt,
endAt: endAt ?? event.endAt,
allDay: event.allDay,
status: status ?? event.status,
reminderMinutes: event.reminderMinutes,
createdBy: event.createdBy,
location: event.location,
createdAt: event.createdAt
)
if updatedEvent.status == "cancelled" {
calendarEvents.removeAll { $0.calendarEventId == event.calendarEventId }
locallyCreatedCalendarEvents.removeAll { $0.calendarEventId == event.calendarEventId }
Self.saveLocallyCreatedCalendarEvents(locallyCreatedCalendarEvents.filter { $0.calendarEventId.hasPrefix("local-") })
} else {
upsertLocalCalendarEvent(updatedEvent, persist: shouldPersistFallback)
calendarEvents = mergedCalendarEvents(with: calendarEvents)
}
refreshPendingTaskMetricCount()
errorMessage = nil
await refresh(silent: true)
return updatedEvent
}
func cancelCalendarEvent(_ event: VelocityCalendarEventDTO) async throws {
var shouldPersistFallback = event.calendarEventId.hasPrefix("local-")
do {
try await VelocityAPIClient.shared.cancelCalendarEvent(calendarEventId: event.calendarEventId)
} catch let error as VelocityAPIError where error.isRecoverableCalendarCreateFailure {
shouldPersistFallback = true
}
let cancelledEvent = VelocityCalendarEventDTO(
calendarEventId: event.calendarEventId,
leadId: event.leadId,
title: event.title,
description: event.description,
startAt: event.startAt,
endAt: event.endAt,
allDay: event.allDay,
status: "cancelled",
reminderMinutes: event.reminderMinutes,
createdBy: event.createdBy,
location: event.location,
createdAt: event.createdAt
)
upsertLocalCalendarEvent(cancelledEvent, persist: shouldPersistFallback)
calendarEvents.removeAll { $0.calendarEventId == event.calendarEventId }
refreshPendingTaskMetricCount()
errorMessage = nil
await refresh(silent: true)
}
func updateLeadStage(
leadId: String,
status: String,
notes: String? = nil
) async throws -> VelocityLeadStageUpdateDTO {
let updatedLead = try await VelocityAPIClient.shared.updateLeadStage(
leadId: leadId,
status: status,
notes: notes
)
await refresh(silent: true)
return updatedLead
}
func updateOpportunity(
opportunityId: String,
stage: String? = nil,
probability: Int? = nil,
nextAction: String? = nil,
notes: String? = nil
) async throws -> VelocityOpportunityDTO {
let updatedOpportunity = try await VelocityAPIClient.shared.updateOpportunity(
opportunityId: opportunityId,
stage: stage,
probability: probability,
nextAction: nextAction,
notes: notes
)
await refresh(silent: true)
return updatedOpportunity
}
private func locallyResolveMissingTask(
reminderId: String,
status: String,
dueAt: String?
) -> VelocityTaskDTO {
if status.lowercased() == "cancelled" {
locallyResolvedTaskIDs.insert(reminderId)
}
let existing = tasks.first { $0.reminderId == reminderId }
return VelocityTaskDTO(
reminderId: reminderId,
reminderType: existing?.reminderType ?? "follow_up",
title: existing?.title ?? "Calendar task",
notes: existing?.notes,
dueAt: dueAt ?? existing?.dueAt,
status: status,
priority: existing?.priority ?? "normal",
personId: existing?.personId,
clientName: existing?.clientName,
clientPhone: existing?.clientPhone
)
}
private func locallyMutatedTask(
from task: VelocityTaskDTO,
status: String,
dueAt: String?
) -> VelocityTaskDTO {
VelocityTaskDTO(
reminderId: task.reminderId,
reminderType: task.reminderType,
title: task.title,
notes: task.notes,
dueAt: dueAt ?? task.dueAt,
status: status,
priority: task.priority,
personId: task.personId,
clientName: task.clientName,
clientPhone: task.clientPhone
)
}
private func upsertLocalTaskOverride(_ task: VelocityTaskDTO) {
localTaskOverrides[task.reminderId] = task
Self.saveLocallyMutatedTasks(Array(localTaskOverrides.values))
Self.saveLocallyHiddenTaskIDs(Array(locallyResolvedTaskIDs))
}
private func mergedTasks(with fetchedTasks: [VelocityTaskDTO]) -> [VelocityTaskDTO] {
var taskByID = Dictionary(uniqueKeysWithValues: fetchedTasks.map { ($0.reminderId, $0) })
for task in localTaskOverrides.values {
taskByID[task.reminderId] = task
}
let visibleTasks = taskByID.values.filter { !locallyResolvedTaskIDs.contains($0.reminderId) }
return VelocityTaskDTO.sortedForOperatorReview(Array(visibleTasks))
}
private func refreshPendingTaskMetricCount() {
var localDelta = 0
for task in localTaskOverrides.values {
let isCanonicalPending = canonicalPendingTaskIDs.contains(task.reminderId)
let isLocallyPending = task.status.lowercased() == "pending"
if isCanonicalPending && !isLocallyPending {
localDelta -= 1
} else if !isCanonicalPending && isLocallyPending {
localDelta += 1
}
}
let locallyHiddenPendingCount = locallyResolvedTaskIDs
.filter { canonicalPendingTaskIDs.contains($0) }
.count
let normalCalendarTaskCount = calendarEvents.filter { event in
event.status.lowercased() == "tentative"
}.count
pendingTaskMetricCount = max(
0,
canonicalPendingTaskCount + localDelta - locallyHiddenPendingCount + normalCalendarTaskCount
)
}
private static func loadLocallyMutatedTasks() -> [String: VelocityTaskDTO] {
guard let data = UserDefaults.standard.data(forKey: locallyMutatedTasksKey),
let persistedTasks = try? JSONDecoder().decode([PersistedTask].self, from: data)
else {
return [:]
}
return Dictionary(uniqueKeysWithValues: persistedTasks.map { ($0.reminderId, $0.task) })
}
private static func saveLocallyMutatedTasks(_ tasks: [VelocityTaskDTO]) {
let persistedTasks = tasks.map(PersistedTask.init(task:))
if let data = try? JSONEncoder().encode(persistedTasks) {
UserDefaults.standard.set(data, forKey: locallyMutatedTasksKey)
}
}
private static func loadLocallyHiddenTaskIDs() -> Set<String> {
let ids = UserDefaults.standard.stringArray(forKey: locallyHiddenTaskIDsKey) ?? []
return Set(ids)
}
private static func saveLocallyHiddenTaskIDs(_ taskIDs: [String]) {
UserDefaults.standard.set(taskIDs, forKey: locallyHiddenTaskIDsKey)
}
private func upsertLocalCalendarEvent(_ event: VelocityCalendarEventDTO, persist: Bool) {
locallyCreatedCalendarEvents.removeAll { $0.calendarEventId == event.calendarEventId }
locallyCreatedCalendarEvents.append(event)
if persist {
Self.saveLocallyCreatedCalendarEvents(locallyCreatedCalendarEvents)
}
}
private func mergedCalendarEvents(with fetchedEvents: [VelocityCalendarEventDTO]) -> [VelocityCalendarEventDTO] {
var eventByID = Dictionary(uniqueKeysWithValues: fetchedEvents.map { ($0.calendarEventId, $0) })
for event in locallyCreatedCalendarEvents {
eventByID[event.calendarEventId] = event
}
return eventByID.values.filter { $0.status != "cancelled" }.sorted {
($0.startDate ?? .distantFuture) < ($1.startDate ?? .distantFuture)
}
}
private static func loadLocallyCreatedCalendarEvents() -> [VelocityCalendarEventDTO] {
guard let data = UserDefaults.standard.data(forKey: locallyCreatedCalendarEventsKey),
let persistedEvents = try? JSONDecoder().decode([PersistedCalendarEvent].self, from: data)
else {
return []
}
return persistedEvents.map(\.event)
}
private static func saveLocallyCreatedCalendarEvents(_ events: [VelocityCalendarEventDTO]) {
let persistedEvents = events.map(PersistedCalendarEvent.init(event:))
if let data = try? JSONEncoder().encode(persistedEvents) {
UserDefaults.standard.set(data, forKey: locallyCreatedCalendarEventsKey)
}
}
private func makeRefreshTask() -> Task<RefreshSnapshot, Error> {
let cachedContacts = contacts
return Task {
async let tasksTask = fetchCalendarTasks()
async let kanbanTask: [VelocityKanbanColumnDTO]? = try? await VelocityAPIClient.shared.fetchKanbanBoard()
async let opportunitiesTask: [VelocityOpportunityDTO]? = try? await VelocityAPIClient.shared.fetchOpportunities()
async let propertiesTask: [VelocityPropertyDTO]? = try? await VelocityAPIClient.shared.fetchProperties(
limit: AppStoreRefreshPolicy.inventoryPropertyLimit
)
async let calendarTask: [VelocityCalendarEventDTO]? = try? await VelocityAPIClient.shared.fetchCalendarEvents()
async let alertsTask: VelocityAlertSnapshotDTO? = try? await VelocityAPIClient.shared.fetchAlerts()
let fetchedContacts: [VelocityCanonicalContactListItemDTO]
do {
fetchedContacts = try await VelocityAPIClient.shared.fetchContacts()
} catch let error as VelocityAPIError where error.statusCode == 404 {
fetchedContacts = cachedContacts
}
let fetchedLeads = VelocityLeadDTO.activeLeadSummaries(from: fetchedContacts)
let taskRefresh = await tasksTask
let fetchedTasks = taskRefresh.tasks.filter { !locallyResolvedTaskIDs.contains($0.reminderId) }
let fetchedKanban = await kanbanTask ?? []
let fetchedOpportunities = await opportunitiesTask ?? []
let fetchedProperties = await propertiesTask ?? []
let fetchedCalendar = await calendarTask ?? []
let fetchedAlerts = await alertsTask ?? VelocityAlertSnapshotDTO.empty
let leadEvents = await fetchLeadEvents(for: fetchedLeads)
return RefreshSnapshot(
contacts: fetchedContacts,
leads: fetchedLeads,
tasks: fetchedTasks,
pendingTaskCount: taskRefresh.pendingTaskCount,
pendingTaskIDs: taskRefresh.pendingTaskIDs,
kanbanColumns: fetchedKanban,
opportunities: fetchedOpportunities,
properties: fetchedProperties,
calendarEvents: fetchedCalendar,
alertSnapshot: fetchedAlerts,
leadEvents: leadEvents
)
}
}
private func fetchCalendarTasks() async -> CalendarTaskRefresh {
async let allTasks: [VelocityTaskDTO]? = try? await VelocityAPIClient.shared.fetchTasks(status: "all")
async let pendingTasks: [VelocityTaskDTO]? = try? await VelocityAPIClient.shared.fetchTasks(status: "pending")
async let confirmedTasks: [VelocityTaskDTO]? = try? await VelocityAPIClient.shared.fetchTasks(status: "confirmed")
async let doneTasks: [VelocityTaskDTO]? = try? await VelocityAPIClient.shared.fetchTasks(status: "done")
async let snoozedTasks: [VelocityTaskDTO]? = try? await VelocityAPIClient.shared.fetchTasks(status: "snoozed")
let fetchedAllTasks = await allTasks ?? []
let pendingTaskResponse = await pendingTasks
let fetchedPendingTasks = pendingTaskResponse ?? []
let fetchedConfirmedTasks = await confirmedTasks ?? []
let fetchedDoneTasks = await doneTasks ?? []
let fetchedSnoozedTasks = await snoozedTasks ?? []
var taskByID: [String: VelocityTaskDTO] = [:]
for task in fetchedAllTasks {
taskByID[task.reminderId] = task
}
for task in fetchedPendingTasks {
taskByID[task.reminderId] = task
}
for task in fetchedConfirmedTasks {
taskByID[task.reminderId] = task
}
for task in fetchedDoneTasks {
taskByID[task.reminderId] = task
}
for task in fetchedSnoozedTasks {
taskByID[task.reminderId] = task
}
let pendingTaskCount = pendingTaskResponse?.count ?? fetchedAllTasks.filter { $0.status.lowercased() == "pending" }.count
let pendingTaskIDs = Set(fetchedPendingTasks.map(\.reminderId))
return CalendarTaskRefresh(
tasks: VelocityTaskDTO.sortedForOperatorReview(Array(taskByID.values)),
pendingTaskCount: pendingTaskCount,
pendingTaskIDs: pendingTaskIDs
)
}
private func fetchLeadEvents(
for leads: [VelocityLeadDTO]
) async -> [String: [VelocityCommunicationEventDTO]] {
let prioritizedLeadIDs = AppStoreRefreshPolicy.prioritizedLeadIDs(from: leads)
return await withTaskGroup(
of: (String, [VelocityCommunicationEventDTO]).self,
returning: [String: [VelocityCommunicationEventDTO]].self
) { group in
for leadID in prioritizedLeadIDs {
group.addTask {
do {
let events = try await VelocityAPIClient.shared.fetchEvents(
for: leadID,
limit: AppStoreRefreshPolicy.leadEventLimitPerLead
)
return (leadID, events)
} catch {
return (leadID, [])
}
}
}
var eventMap: [String: [VelocityCommunicationEventDTO]] = [:]
for await (leadID, events) in group {
eventMap[leadID] = events
}
return eventMap
}
}
}
struct TimelineEvent: Identifiable {
let leadId: String
let event: VelocityCommunicationEventDTO
let leadName: String
var id: String { event.id }
var date: Date { event.timestampDate ?? .distantPast }
}
extension VelocityCalendarEventDTO {
var startsToday: Bool {
guard let date = startDate else { return false }
return Calendar.current.isDateInToday(date)
}
}
extension Date {
var relativeShort: String {
let delta = Int(Date().timeIntervalSince(self))
if delta < 60 { return "now" }
if delta < 3600 { return "\(delta / 60)m ago" }
if delta < 86400 { return "\(delta / 3600)h ago" }
return "\(delta / 86400)d ago"
}
var taskDueLabel: String {
if Calendar.current.isDateInToday(self) {
let formatter = DateFormatter()
formatter.dateFormat = "'Today' · h:mm a"
return formatter.string(from: self)
}
if Calendar.current.isDateInTomorrow(self) {
let formatter = DateFormatter()
formatter.dateFormat = "'Tomorrow' · h:mm a"
return formatter.string(from: self)
}
let formatter = DateFormatter()
formatter.dateFormat = "EEE, MMM d · h:mm a"
return formatter.string(from: self)
}
}
private extension VelocityAPIError {
var isRecoverableCalendarCreateFailure: Bool {
if let statusCode {
return statusCode == 404 || (500...599).contains(statusCode)
}
if case .invalidResponse = self {
return true
}
return false
}
}

View File

@@ -0,0 +1,31 @@
import Foundation
enum AppStoreRefreshPolicy {
/// Match the WebOS bootstrap so Inventory, Dashboard, and shared summaries
/// are based on the same production property slice by default.
static let inventoryPropertyLimit = 100
/// Keep the canonical CRM follow-up inbox bounded while still representing
/// 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.
static let leadTimelineHydrationLimit = 6
/// Fetch enough recent communication context for the visible iPad rails
/// without inflating each refresh unnecessarily.
static let leadEventLimitPerLead = 4
static func prioritizedLeadIDs(
from leads: [VelocityLeadDTO],
limit: Int = leadTimelineHydrationLimit
) -> [String] {
Array(
leads
.sorted(by: { $0.score > $1.score })
.prefix(limit)
.map(\.id)
)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,490 @@
import Combine
import SwiftUI
struct ClientsView: View {
@State private var store = AppStore.shared
@State private var searchText = ""
@State private var selectedClient360: VelocityClient360DTO?
@State private var selectedPersonID: String?
@State private var isClient360Loading = false
@State private var client360Error: String?
private let refreshTimer = Timer.publish(every: 30, on: .main, in: .common).autoconnect()
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
.padding(.horizontal, 24)
.padding(.top, 24)
.padding(.bottom, 16)
if let error = store.errorMessage {
errorBanner(error)
.padding(.horizontal, 24)
.padding(.bottom, 14)
}
ScrollView {
VStack(alignment: .leading, spacing: 16) {
summaryPanel
searchPanel
contactsPanel
}
.padding(.horizontal, 24)
.padding(.bottom, 24)
}
}
.background(VelocityTheme.background)
.task { await store.refresh() }
.refreshable { await store.refresh() }
.onReceive(refreshTimer) { _ in
Task { await store.refresh(silent: true) }
}
.sheet(isPresented: client360PresentationBinding) {
client360Sheet
}
}
private var header: some View {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
Text("Clients")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text("Canonical CRM contact workspace backed by `/api/crm/client-data` and client detail APIs.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
Text(store.lastRefreshAt?.relativeShort ?? "Awaiting sync")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(VelocityTheme.mutedFg)
}
}
private var summaryPanel: some View {
HStack(spacing: 12) {
metricCard("Contacts", value: "\(store.contacts.count)", color: VelocityTheme.accent)
metricCard("Active Leads", value: "\(store.leads.count)", color: VelocityTheme.success)
metricCard("Open Tasks", value: "\(store.metrics.pendingTaskCount)", color: VelocityTheme.warning)
metricCard("High Intent", value: "\(highIntentCount)", color: VelocityTheme.danger)
}
}
private var searchPanel: some View {
HStack(spacing: 12) {
Image(systemName: "magnifyingglass")
.foregroundStyle(VelocityTheme.mutedFg)
TextField("Search by name, phone, interest, budget, or status", text: $searchText)
.textInputAutocapitalization(.words)
.foregroundStyle(VelocityTheme.foreground)
if !searchText.isEmpty {
Button("Clear") {
searchText = ""
}
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(VelocityTheme.accent)
}
}
.padding(16)
.glassCard(cornerRadius: 16)
}
private var contactsPanel: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Canonical Contacts")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Spacer()
Text("\(filteredContacts.count) shown")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(VelocityTheme.mutedFg)
}
if store.isLoading && store.lastRefreshAt == nil {
loadingCard
} else if store.contacts.isEmpty {
emptyCard("No canonical contacts were returned for this operator scope yet.")
} else if filteredContacts.isEmpty {
emptyCard("No canonical contacts match this search.")
} else {
LazyVStack(spacing: 10) {
ForEach(filteredContacts) { contact in
contactCard(contact)
}
}
}
}
.padding(18)
.glassCard(cornerRadius: 20)
}
private func contactCard(_ contact: VelocityCanonicalContactListItemDTO) -> some View {
Button {
openClient360(for: contact.personId)
} label: {
HStack(alignment: .top, spacing: 14) {
ZStack {
Circle()
.fill(VelocityTheme.accent.opacity(0.14))
.frame(width: 42, height: 42)
Text(initials(for: contact.fullName))
.font(.system(size: 13, weight: .bold))
.foregroundStyle(VelocityTheme.accent)
}
VStack(alignment: .leading, spacing: 7) {
HStack {
Text(contact.fullName)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Spacer()
Text("\(contact.displayIntentScore)")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(VelocityTheme.accent)
}
Text("\(contact.buyerTypeLabel) · \(contact.leadStatusLabel)")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.foreground)
Text("\(contact.budgetSummary) · \(contact.interestSummary)")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
Text("\(contact.contactLine) · \(contact.pendingTasks) pending tasks · \(contact.interactionCount) interactions")
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.mutedFg)
}
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(VelocityTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
)
)
}
.buttonStyle(.plain)
}
private var loadingCard: some View {
HStack(spacing: 12) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
Text("Loading canonical contacts...")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
.background(RoundedRectangle(cornerRadius: 16).fill(VelocityTheme.surface))
}
private func emptyCard(_ message: String) -> some View {
Text(message)
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.mutedFg)
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
.background(RoundedRectangle(cornerRadius: 16).fill(VelocityTheme.surface))
}
private func metricCard(_ label: String, value: String, color: Color) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(label.uppercased())
.font(.system(size: 10, weight: .semibold))
.tracking(1)
.foregroundStyle(VelocityTheme.mutedFg)
Text(value)
.font(.system(size: 21, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
RoundedRectangle(cornerRadius: 3)
.fill(color)
.frame(width: 42, height: 4)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.glassCard(cornerRadius: 16)
}
private var filteredContacts: [VelocityCanonicalContactListItemDTO] {
let normalized = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !normalized.isEmpty else {
return store.contacts
}
return store.contacts.filter { contact in
[
contact.fullName,
contact.primaryPhone ?? "",
contact.buyerType ?? "",
contact.leadStatus ?? "",
contact.budgetBand ?? "",
contact.primaryInterest ?? "",
contact.urgency ?? "",
]
.joined(separator: " ")
.lowercased()
.contains(normalized)
}
}
private var highIntentCount: Int {
store.contacts.filter { $0.displayIntentScore >= 80 }.count
}
private func initials(for name: String) -> String {
let initials = name
.split(separator: " ")
.prefix(2)
.compactMap(\.first)
return initials.isEmpty ? "C" : String(initials)
}
private var client360PresentationBinding: Binding<Bool> {
Binding(
get: { selectedPersonID != nil },
set: { isPresented in
if !isPresented {
selectedPersonID = nil
selectedClient360 = nil
client360Error = nil
isClient360Loading = false
}
}
)
}
@ViewBuilder
private var client360Sheet: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if isClient360Loading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
.frame(maxWidth: .infinity, alignment: .center)
.padding(.top, 40)
} else if let client360Error {
errorBanner(client360Error)
} else if let snapshot = selectedClient360 {
client360Snapshot(snapshot)
}
}
.padding(20)
}
.background(VelocityTheme.background)
.navigationTitle("Client 360")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") {
selectedPersonID = nil
selectedClient360 = nil
client360Error = nil
isClient360Loading = false
}
}
}
}
}
private func client360Snapshot(_ snapshot: VelocityClient360DTO) -> some View {
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 8) {
Text(snapshot.identity.fullName)
.font(.system(size: 24, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text("\(snapshot.identity.primaryPhone ?? "No phone") · \(snapshot.identity.buyerType?.replacingOccurrences(of: "_", with: " ").capitalized ?? "CRM contact")")
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.mutedFg)
if let email = snapshot.identity.primaryEmail {
Text(email)
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
if !snapshot.identity.personaLabels.isEmpty {
Text(snapshot.identity.personaLabels.joined(separator: " · "))
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(VelocityTheme.accent)
}
}
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
.glassCard(cornerRadius: 18)
VStack(alignment: .leading, spacing: 10) {
if let lead = snapshot.currentLead {
sectionLine("Lead", value: "\(lead.status.replacingOccurrences(of: "_", with: " ").capitalized) · \(lead.budgetBand ?? "Budget pending")")
sectionLine("Urgency", value: lead.urgency?.replacingOccurrences(of: "_", with: " ").capitalized ?? "Normal")
if !lead.motivations.isEmpty {
Text("Motivations: \(lead.motivations.joined(separator: ", "))")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
if !lead.objections.isEmpty {
Text("Objections: \(lead.objections.joined(separator: ", "))")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
} else {
Text("No active canonical lead context was returned for this client.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
sectionLine("Opportunities", value: "\(snapshot.activeOpportunities.count)")
sectionLine("Tasks", value: "\(snapshot.tasks.count)")
sectionLine("Interactions", value: "\(snapshot.recentInteractions.count)")
}
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
.glassCard(cornerRadius: 18)
if !snapshot.activeOpportunities.isEmpty {
client360ListCard(title: "Active Opportunities") {
ForEach(snapshot.activeOpportunities) { opportunity in
VStack(alignment: .leading, spacing: 5) {
Text(opportunity.stage.replacingOccurrences(of: "_", with: " ").capitalized)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text("\(opportunity.formattedValue) · \(opportunity.probabilityLabel)")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
Text(opportunity.nextAction ?? "Next action pending")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(.vertical, 4)
}
}
}
if !snapshot.propertyInterests.isEmpty {
client360ListCard(title: "Property Interests") {
ForEach(snapshot.propertyInterests) { interest in
VStack(alignment: .leading, spacing: 5) {
Text(interest.projectName)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text([interest.configuration, interest.unitPreference].compactMap { nonEmpty($0) }.joined(separator: " · "))
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(.vertical, 4)
}
}
}
client360ListCard(title: "Recent Interactions") {
if snapshot.recentInteractions.isEmpty {
Text("No recent canonical interactions were returned for this client.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
} else {
ForEach(snapshot.recentInteractions) { interaction in
VStack(alignment: .leading, spacing: 5) {
Text("\(interaction.channel.capitalized) · \(interaction.interactionType.replacingOccurrences(of: "_", with: " ").capitalized)")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text(interaction.summary ?? "No summary captured")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(.vertical, 4)
}
}
}
if !snapshot.tasks.isEmpty || !snapshot.recommendedNextActions.isEmpty || !snapshot.riskFlags.isEmpty {
client360ListCard(title: "Operator Actions") {
ForEach(snapshot.tasks) { task in
sectionLine(task.title, value: "\(task.priorityLabel) · \(task.dueLabel)")
}
ForEach(snapshot.recommendedNextActions, id: \.self) { action in
Text(action)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(VelocityTheme.accent)
}
ForEach(snapshot.riskFlags, id: \.self) { flag in
Text(flag.replacingOccurrences(of: "_", with: " ").capitalized)
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.warning)
}
}
}
}
}
private func client360ListCard<Content: View>(
title: String,
@ViewBuilder content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text(title)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
content()
}
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
.glassCard(cornerRadius: 18)
}
private func sectionLine(_ title: String, value: String) -> some View {
HStack {
Text(title)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(VelocityTheme.mutedFg)
Spacer()
Text(value)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
}
}
private func nonEmpty(_ value: String?) -> String? {
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
private func openClient360(for personId: String) {
selectedPersonID = personId
selectedClient360 = nil
client360Error = nil
isClient360Loading = true
Task {
do {
let snapshot = try await VelocityAPIClient.shared.fetchClient360(personId: personId)
await MainActor.run {
selectedClient360 = snapshot
isClient360Loading = false
}
} catch {
await MainActor.run {
selectedClient360 = nil
client360Error = error.localizedDescription
isClient360Loading = false
}
}
}
}
private func errorBanner(_ message: String) -> some View {
Text(message)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(VelocityTheme.danger)
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(VelocityTheme.danger.opacity(0.10))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(VelocityTheme.danger.opacity(0.22), lineWidth: 1)
)
)
}
}
#Preview {
ClientsView()
}

View File

@@ -1,3 +1,4 @@
import Combine
import SwiftUI
private struct CommunicationThread: Identifiable {
@@ -65,7 +66,7 @@ struct CommunicationsView: View {
Text("Communications")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text("Phone, WhatsApp, transcript, and memory edge across active leads.")
Text("Phone, WhatsApp, transcript, and memory edge across canonical CRM contacts with active lead context.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
@@ -116,7 +117,7 @@ struct CommunicationsView: View {
.foregroundStyle(VelocityTheme.foreground)
if threads.isEmpty {
detailRow(title: "Live data", value: "No communication events have been captured for current leads yet.")
detailRow(title: "Live data", value: "No communication events have been captured for the current canonical CRM lead set yet.")
}
ForEach(threads) { thread in
@@ -279,7 +280,7 @@ struct CommunicationsView: View {
Text("Loading live communications...")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text("Velocity is fetching current leads, communication events, and alert state from the backend.")
Text("Velocity is fetching canonical CRM contact summaries, communication events, and alert state from the backend.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
@@ -318,7 +319,7 @@ struct CommunicationsView: View {
var fetchedThreads: [CommunicationThread] = []
for lead in topLeads {
let events = try await VelocityAPIClient.shared.fetchEvents(for: lead.id, limit: 1)
let events = (try? await VelocityAPIClient.shared.fetchEvents(for: lead.id, limit: 1)) ?? []
let latest = events.first
fetchedThreads.append(
CommunicationThread(
@@ -339,7 +340,7 @@ struct CommunicationsView: View {
await MainActor.run {
threads = fetchedThreads
alerts = fetchedAlerts
if selectedThread == nil {
if selectedThread == nil || !fetchedThreads.contains(where: { $0.id == selectedThread }) {
selectedThread = fetchedThreads.first?.id
}
errorMessage = nil
@@ -383,6 +384,9 @@ struct CommunicationsView: View {
private func statusLabel(for lead: VelocityLeadDTO, event: VelocityCommunicationEventDTO?) -> String {
if event == nil {
if lead.pendingTaskCount > 0 {
return "Task pending"
}
return "No events yet"
}
if lead.score >= 90 {
@@ -395,6 +399,9 @@ struct CommunicationsView: View {
if event?.recordingRef != nil {
return "Review transcript"
}
if lead.pendingTaskCount > 0 {
return lead.pendingTaskCount == 1 ? "Review pending task" : "Review \(lead.pendingTaskCount) tasks"
}
if lead.score >= 90 {
return "Schedule follow-up"
}

View File

@@ -1,7 +1,9 @@
import Combine
import SwiftUI
struct DashboardView: View {
@State private var store = AppStore.shared
@State private var session = SessionStore.shared
private let refreshTimer = Timer.publish(every: 20, on: .main, in: .common).autoconnect()
private let columns = [GridItem(.adaptive(minimum: 220), spacing: 14)]
@@ -19,6 +21,7 @@ struct DashboardView: View {
} else {
metricsGrid
liveStatusPanel
followUpLoadPanel
leadFocusPanel
inventoryPanel
}
@@ -40,15 +43,15 @@ struct DashboardView: View {
Text("Dashboard")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text("Live mobile operator posture for leads, inventory, and follow-up load.")
Text("Live mobile operator posture for canonical CRM pipeline, inventory, and follow-up load.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
VStack(alignment: .trailing, spacing: 8) {
statusBadge(
label: store.isConfigured ? "Live backend" : "Config required",
color: store.isConfigured ? VelocityTheme.success : VelocityTheme.warning
label: session.isConfigured ? "Live backend" : "Config required",
color: session.isConfigured ? VelocityTheme.success : VelocityTheme.warning
)
if let lastRefresh = store.lastRefreshAt {
Text("Updated \(lastRefresh.relativeShort)")
@@ -63,6 +66,8 @@ struct DashboardView: View {
LazyVGrid(columns: columns, spacing: 14) {
MetricCard(title: "Leads", value: "\(store.metrics.leadCount)", subtitle: "Live CRM records", color: VelocityTheme.accent)
MetricCard(title: "Whale Leads", value: "\(store.metrics.whaleLeadCount)", subtitle: "Score 90+ or whale qualified", color: VelocityTheme.success)
MetricCard(title: "Pending Tasks", value: "\(store.metrics.pendingTaskCount)", subtitle: "Canonical CRM reminders", color: VelocityTheme.warning)
MetricCard(title: "Urgent Tasks", value: "\(store.metrics.urgentTaskCount)", subtitle: "High and urgent follow-ups", color: VelocityTheme.danger)
MetricCard(title: "Inventory", value: "\(store.metrics.propertyCount)", subtitle: "Property rows available", color: VelocityTheme.warning)
MetricCard(title: "Today", value: "\(store.metrics.todayCalendarCount)", subtitle: "Calendar slots scheduled", color: Color(red: 0.60, green: 0.57, blue: 0.99))
}
@@ -75,11 +80,12 @@ struct DashboardView: View {
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Spacer()
statusBadge(label: AppConfig.authModeDescription, color: VelocityTheme.accent)
statusBadge(label: session.authModeDescription, color: VelocityTheme.accent)
}
detailRow(title: "Endpoint", value: AppConfig.baseURL)
detailRow(title: "Operator", value: store.operatorIdentity)
detailRow(title: "Endpoint", value: session.endpointDisplay)
detailRow(title: "Operator", value: session.operatorIdentity)
detailRow(title: "Pending CRM tasks", value: "\(store.metrics.pendingTaskCount)")
detailRow(title: "Pending insights", value: "\(store.metrics.pendingInsights)")
detailRow(title: "Pending transcriptions", value: "\(store.metrics.pendingTranscriptions)")
}
@@ -87,14 +93,58 @@ struct DashboardView: View {
.glassCard(cornerRadius: 18)
}
private var followUpLoadPanel: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Follow-Up Load")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
if store.prioritizedTasks.isEmpty {
emptyMessage("No canonical CRM reminder tasks are pending for this operator right now.")
} else {
ForEach(store.prioritizedTasks.prefix(4)) { task in
HStack(alignment: .top, spacing: 14) {
VStack(alignment: .leading, spacing: 5) {
Text(task.title)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text("\(task.ownerLabel) · \(task.dueLabel)")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
Text(taskNote(task))
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
.lineLimit(2)
}
Spacer()
Text(task.priorityLabel)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(priorityColor(for: task.priority))
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(VelocityTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
)
)
}
}
}
.padding(20)
.glassCard(cornerRadius: 18)
}
private var leadFocusPanel: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Lead Focus")
Text("Client Focus")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
if store.highlightedLeads.isEmpty {
emptyMessage("No live leads have been returned by the backend yet.")
emptyMessage("No canonical CRM contacts with active lead context have been returned by the backend yet.")
} else {
ForEach(store.highlightedLeads) { lead in
HStack(alignment: .top, spacing: 14) {
@@ -209,7 +259,7 @@ struct DashboardView: View {
Text("Loading live dashboard data...")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text("Velocity is reading leads, alerts, calendar events, and inventory summaries from the backend.")
Text("Velocity is reading canonical CRM contacts, reminders, alerts, calendar events, and inventory summaries from the backend.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
@@ -232,6 +282,22 @@ struct DashboardView: View {
)
)
}
private func priorityColor(for priority: String) -> Color {
switch priority.lowercased() {
case "urgent":
return VelocityTheme.danger
case "high":
return VelocityTheme.warning
default:
return VelocityTheme.accent
}
}
private func taskNote(_ task: VelocityTaskDTO) -> String {
let note = task.notes?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return note.isEmpty ? "No operator note yet." : note
}
}
private struct MetricCard: View {

View File

@@ -0,0 +1,467 @@
import Combine
import SwiftUI
struct ImportsView: View {
@State private var batches: [VelocityImportBatchSummaryDTO] = []
@State private var selectedBatch: VelocityImportBatchSummaryDTO?
@State private var detail: VelocityImportBatchDetailDTO?
@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()
var body: some View {
HStack(spacing: 0) {
batchRail
.frame(width: 350)
.background(VelocityTheme.sidebarBg)
Divider()
.background(VelocityTheme.borderSubtle)
detailPane
}
.background(VelocityTheme.background)
.task { await loadBatches(selectFirst: true) }
.refreshable { await loadBatches(selectFirst: false) }
.onReceive(refreshTimer) { _ in
Task { await loadBatches(selectFirst: false, silent: true) }
}
}
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.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(.horizontal, 18)
.padding(.top, 22)
if let errorMessage {
errorBanner(errorMessage)
.padding(.horizontal, 18)
}
if let successMessage {
successBanner(successMessage)
.padding(.horizontal, 18)
}
ScrollView {
LazyVStack(spacing: 10) {
if isLoading && batches.isEmpty {
loadingCard("Loading import batches...")
} else if batches.isEmpty {
emptyCard("No canonical import batches were returned yet.")
} else {
ForEach(batches) { batch in
batchCard(batch)
}
}
}
.padding(.horizontal, 18)
.padding(.bottom, 18)
}
}
}
private var detailPane: some View {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
if let detail {
detailHeader(detail)
proposalsPanel(detail)
} else if isLoading {
loadingCard("Loading import detail...")
} else {
emptyCard("Select an import batch to review canonical proposals.")
}
}
.padding(24)
}
.background(VelocityTheme.background)
}
private func batchCard(_ batch: VelocityImportBatchSummaryDTO) -> some View {
Button {
Task { await selectBatch(batch) }
} label: {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(batch.displayName)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
.lineLimit(1)
Spacer()
Text(batch.lifecycleLabel)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(lifecycleColor(batch.lifecycle))
}
Text("\(batch.rowCount) rows · \(batch.mappedCount ?? 0) mapped · \(batch.unresolvedCount ?? 0) unresolved")
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.mutedFg)
Text(batch.sourceSystem ?? "Unknown source")
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(selectedBatch?.batchId == batch.batchId ? VelocityTheme.accent.opacity(0.14) : VelocityTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(selectedBatch?.batchId == batch.batchId ? VelocityTheme.borderAccent : VelocityTheme.borderSubtle, lineWidth: 1)
)
)
}
.buttonStyle(.plain)
}
private func detailHeader(_ detail: VelocityImportBatchDetailDTO) -> some View {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
Text(detail.filename ?? "CRM import")
.font(.system(size: 24, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text("\(detail.rowCount) rows · \(detail.proposalCount) proposals · \(detail.sourceSystem ?? "Unknown source")")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
Text(detail.lifecycle.replacingOccurrences(of: "_", with: " ").capitalized)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(lifecycleColor(detail.lifecycle))
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Capsule().fill(lifecycleColor(detail.lifecycle).opacity(0.12)))
}
HStack(spacing: 12) {
metricCard("Pending", value: "\(detail.proposals.filter { $0.status == "pending" }.count)", color: VelocityTheme.warning)
metricCard("Approved", value: "\(detail.proposals.filter { $0.status == "approved" }.count)", color: VelocityTheme.success)
metricCard("Rejected", value: "\(detail.proposals.filter { $0.status == "rejected" }.count)", color: VelocityTheme.danger)
}
Button {
Task { await commitSelectedBatch() }
} label: {
HStack {
if isCommitting {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
}
Text(isCommitting ? "Committing..." : "Commit Approved Proposals")
.font(.system(size: 13, weight: .semibold))
}
.foregroundStyle(.white)
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(
Capsule()
.fill(approvedCount(detail) > 0 && !isCommitting ? VelocityTheme.success : VelocityTheme.subtleFg)
)
}
.buttonStyle(.plain)
.disabled(approvedCount(detail) == 0 || isCommitting)
}
.padding(18)
.glassCard(cornerRadius: 20)
}
private func proposalsPanel(_ detail: VelocityImportBatchDetailDTO) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text("Review Proposals")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
if detail.proposals.isEmpty {
emptyCard("No proposals were returned for this import batch.")
} else {
ForEach(detail.proposals) { proposal in
proposalCard(proposal, batchId: detail.batchId)
}
}
}
.padding(18)
.glassCard(cornerRadius: 20)
}
private func proposalCard(_ proposal: VelocityImportProposalDTO, batchId: String) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
VStack(alignment: .leading, spacing: 3) {
Text(proposal.rowLabel)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text("\(proposal.confidencePercent)% confidence · \(proposal.status.capitalized)")
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
if activeProposalID == proposal.proposalId {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
} else {
proposalActions(proposal, batchId: batchId)
}
}
if let canonical = proposal.payload?.canonicalPayload, !canonical.isEmpty {
Text(canonicalPreview(canonical))
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.foreground)
.lineLimit(3)
}
if let missing = proposal.payload?.missingRequired, !missing.isEmpty {
Text("Missing: \(missing.joined(separator: ", "))")
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.danger)
}
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(VelocityTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
)
)
}
private func proposalActions(_ proposal: VelocityImportProposalDTO, batchId: String) -> some View {
HStack(spacing: 8) {
Button("Approve") {
Task { await reviewProposal(batchId: batchId, proposal: proposal, decision: "approved") }
}
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(VelocityTheme.success)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.contentShape(Rectangle())
.disabled(proposal.status.lowercased() == "approved")
Button("Reject") {
Task { await reviewProposal(batchId: batchId, proposal: proposal, decision: "rejected") }
}
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(VelocityTheme.danger)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.contentShape(Rectangle())
.disabled(proposal.status.lowercased() == "rejected")
}
}
private func loadBatches(selectFirst: Bool, silent: Bool = false) async {
if !silent {
isLoading = true
}
do {
let fetched = try await VelocityAPIClient.shared.fetchImportBatches()
await MainActor.run {
batches = fetched
errorMessage = nil
isLoading = false
}
if selectFirst, selectedBatch == nil, let first = fetched.first {
await selectBatch(first)
} else if let selectedBatch {
await refreshDetail(batchId: selectedBatch.batchId, silent: true)
}
} catch {
await MainActor.run {
errorMessage = error.localizedDescription
isLoading = false
}
}
}
private func selectBatch(_ batch: VelocityImportBatchSummaryDTO) async {
await MainActor.run {
selectedBatch = batch
detail = nil
errorMessage = nil
successMessage = nil
isLoading = true
}
await refreshDetail(batchId: batch.batchId)
}
private func refreshDetail(batchId: String, silent: Bool = false) async {
if !silent {
await MainActor.run { isLoading = true }
}
do {
let fetched = try await VelocityAPIClient.shared.fetchImportBatch(batchId: batchId)
await MainActor.run {
detail = fetched
errorMessage = nil
isLoading = false
}
} catch {
await MainActor.run {
errorMessage = error.localizedDescription
isLoading = false
}
}
}
private func reviewProposal(
batchId: String,
proposal: VelocityImportProposalDTO,
decision: String
) async {
await MainActor.run {
activeProposalID = proposal.proposalId
errorMessage = nil
successMessage = nil
}
do {
_ = try await VelocityAPIClient.shared.reviewImportProposal(
batchId: batchId,
proposalId: proposal.proposalId,
decision: decision,
notes: "Reviewed from iPad Imports workspace."
)
await refreshDetail(batchId: batchId, silent: true)
await MainActor.run {
activeProposalID = nil
successMessage = "Proposal \(decision)."
}
} catch {
await MainActor.run {
activeProposalID = nil
errorMessage = error.localizedDescription
}
}
}
private func commitSelectedBatch() async {
guard let batchId = detail?.batchId else {
return
}
await MainActor.run {
isCommitting = true
errorMessage = nil
successMessage = nil
}
do {
let result = try await VelocityAPIClient.shared.commitImportBatch(batchId: batchId)
await loadBatches(selectFirst: false, silent: true)
await refreshDetail(batchId: batchId, silent: true)
await MainActor.run {
isCommitting = false
successMessage = "Committed \(result.committed), skipped \(result.skipped)."
if !result.errors.isEmpty {
errorMessage = result.errors.joined(separator: " · ")
}
}
} catch {
await MainActor.run {
isCommitting = false
errorMessage = error.localizedDescription
}
}
}
private func approvedCount(_ detail: VelocityImportBatchDetailDTO) -> Int {
detail.proposals.filter { $0.status == "approved" }.count
}
private func canonicalPreview(_ payload: [String: JSONValue]) -> String {
payload
.sorted(by: { $0.key < $1.key })
.prefix(5)
.map { "\($0.key): \($0.value.stringValue ?? "-")" }
.joined(separator: " · ")
}
private func metricCard(_ label: String, value: String, color: Color) -> some View {
VStack(alignment: .leading, spacing: 5) {
Text(label.uppercased())
.font(.system(size: 9, weight: .semibold))
.tracking(1)
.foregroundStyle(VelocityTheme.mutedFg)
Text(value)
.font(.system(size: 18, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
RoundedRectangle(cornerRadius: 3)
.fill(color)
.frame(width: 34, height: 3)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(RoundedRectangle(cornerRadius: 14).fill(VelocityTheme.surface))
}
private func loadingCard(_ message: String) -> some View {
HStack(spacing: 10) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
Text(message)
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(RoundedRectangle(cornerRadius: 16).fill(VelocityTheme.surface))
}
private func emptyCard(_ message: String) -> some View {
Text(message)
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.mutedFg)
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(RoundedRectangle(cornerRadius: 16).fill(VelocityTheme.surface))
}
private func errorBanner(_ message: String) -> some View {
banner(message, color: VelocityTheme.danger)
}
private func successBanner(_ message: String) -> some View {
banner(message, color: VelocityTheme.success)
}
private func banner(_ message: String, color: Color) -> some View {
Text(message)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(color)
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(color.opacity(0.10))
.overlay(RoundedRectangle(cornerRadius: 14).stroke(color.opacity(0.22), lineWidth: 1))
)
}
private func lifecycleColor(_ lifecycle: String) -> Color {
switch lifecycle.lowercased() {
case "committed":
return VelocityTheme.success
case "failed":
return VelocityTheme.danger
case "approved", "proposed", "parsed":
return VelocityTheme.warning
default:
return VelocityTheme.accent
}
}
}
#Preview {
ImportsView()
}

View File

@@ -110,7 +110,7 @@ struct ARSunOverlayView: UIViewRepresentable {
// MARK: - Scene Building
private func buildScene() {
guard let sceneView else { return }
guard sceneView != nil else { return }
// Remove old nodes
arcRootNode.childNodes.forEach { $0.removeFromParentNode() }
@@ -233,7 +233,7 @@ struct ARSunOverlayView: UIViewRepresentable {
private func makeLineNode(through positions: [SCNVector3], color: UIColor) -> SCNNode {
guard positions.count >= 2 else { return SCNNode() }
var vertices: [SCNVector3] = positions
let vertices: [SCNVector3] = positions
var indices: [Int32] = []
for i in 0..<(vertices.count - 1) {
indices.append(Int32(i))

View File

@@ -0,0 +1,35 @@
import Foundation
enum InventoryModeAvailability {
static let dollhouseAssetCandidates: [(name: String, ext: String)] = [
("Building", "usdz"),
("Building", "scn"),
]
static func hasShippedDollhouseAsset(in bundle: Bundle = .main) -> Bool {
dollhouseAssetCandidates.contains { candidate in
bundle.url(forResource: candidate.name, withExtension: candidate.ext) != nil
}
}
static func productionVisibleModes(hasDollhouseAsset: Bool) -> [InventoryStore.Mode] {
var modes: [InventoryStore.Mode] = [.sunseeker, .dreamWeaver]
if hasDollhouseAsset {
modes.append(.dollhouse)
}
return modes
}
static func sanitizedProductionSelection(
_ candidate: InventoryStore.Mode,
hasDollhouseAsset: Bool
) -> InventoryStore.Mode {
productionVisibleModes(hasDollhouseAsset: hasDollhouseAsset).contains(candidate) ? candidate : .sunseeker
}
static func modeSummaryText(hasDollhouseAsset: Bool) -> String {
productionVisibleModes(hasDollhouseAsset: hasDollhouseAsset)
.map(\.rawValue)
.joined(separator: " · ")
}
}

View File

@@ -30,8 +30,29 @@ struct InventoryView: View {
@State private var sliderTickHour = 12
@State private var showShareSheet = false
@State private var shareImage: UIImage? = nil
private let hasDollhouseAsset = InventoryModeAvailability.hasShippedDollhouseAsset()
private let haptics = UIImpactFeedbackGenerator(style: .light)
private var visibleModes: [InventoryStore.Mode] {
InventoryModeAvailability.productionVisibleModes(hasDollhouseAsset: hasDollhouseAsset)
}
private var selectedMode: InventoryStore.Mode {
InventoryModeAvailability.sanitizedProductionSelection(store.mode, hasDollhouseAsset: hasDollhouseAsset)
}
private var modeSelection: Binding<InventoryStore.Mode> {
Binding(
get: { selectedMode },
set: { newValue in
store.mode = InventoryModeAvailability.sanitizedProductionSelection(
newValue,
hasDollhouseAsset: hasDollhouseAsset
)
}
)
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
// Page header share button sits on the same baseline as the title
@@ -40,7 +61,7 @@ struct InventoryView: View {
Text("Inventory")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text("Sunseeker · Dream Weaver · Dollhouse")
Text(InventoryModeAvailability.modeSummaryText(hasDollhouseAsset: hasDollhouseAsset))
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
@@ -53,8 +74,10 @@ struct InventoryView: View {
Image(systemName: "square.and.arrow.up")
.font(.system(size: 20, weight: .medium))
.foregroundStyle(VelocityTheme.foreground)
.padding(8)
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.transition(.opacity.combined(with: .scale))
}
}
@@ -62,8 +85,8 @@ struct InventoryView: View {
.padding(.horizontal, 20)
.padding(.top, 20)
Picker("Mode", selection: $store.mode) {
ForEach(InventoryStore.Mode.allCases) { mode in
Picker("Mode", selection: modeSelection) {
ForEach(visibleModes) { mode in
Text(mode.rawValue).tag(mode)
}
}
@@ -71,8 +94,17 @@ struct InventoryView: View {
.padding(.horizontal, 20)
.padding(.top, 12)
if !hasDollhouseAsset {
ProductionScopeCard(
icon: "cube.transparent",
title: "Dollhouse hidden in this production build",
message: "Dollhouse stays out of the production iPad scope until a verified Building.usdz or Building.scn asset is shipped in the app bundle."
)
.padding(.horizontal, 20)
}
Group {
switch store.mode {
switch selectedMode {
case .sunseeker:
#if targetEnvironment(simulator)
SimulatorUnavailableCard(
@@ -114,6 +146,7 @@ struct InventoryView: View {
}
)
.onAppear {
store.mode = selectedMode
UISegmentedControl.appearance().selectedSegmentTintColor = UIColor(
red: 0.231, green: 0.510, blue: 0.965, alpha: 0.85)
UISegmentedControl.appearance().setTitleTextAttributes(
@@ -174,6 +207,42 @@ private struct SimulatorUnavailableCard: View {
}
}
private struct ProductionScopeCard: View {
let icon: String
let title: String
let message: String
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: icon)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(VelocityTheme.accent)
.padding(.top, 2)
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text(message)
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 0)
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(VelocityTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(VelocityTheme.borderAccent, lineWidth: 1)
)
)
}
}
// MARK: - Sunseeker
private struct SunseekerPanel: View {
@@ -416,6 +485,7 @@ private struct DreamWeaverPanel: View {
}
.buttonStyle(.borderedProminent)
.frame(maxWidth: .infinity)
.disabled(isProcessing)
}
.padding(16)
.background {
@@ -605,7 +675,13 @@ private struct SceneKitDollhouseView: UIViewRepresentable {
private let sunNode = SCNNode()
func setupScene() {
if let modelScene = SCNScene(named: "Building.usdz") ?? SCNScene(named: "Building.scn") {
let modelScene = InventoryModeAvailability.dollhouseAssetCandidates
.compactMap { candidate in
SCNScene(named: "\(candidate.name).\(candidate.ext)")
}
.first
if let modelScene {
let container = SCNNode()
for child in modelScene.rootNode.childNodes {
container.addChildNode(child.clone())

View File

@@ -40,7 +40,6 @@ struct SimulatorSunOverlayView: UIViewRepresentable {
func updateUIView(_ uiView: SCNView, context: Context) {}
final class Coordinator: NSObject {
private weak var sceneView: SCNView?
@Binding private var sunNodesReady: Bool
private let mockLocation: CLLocationCoordinate2D
@@ -59,7 +58,6 @@ struct SimulatorSunOverlayView: UIViewRepresentable {
}
func attach(to view: SCNView) {
self.sceneView = view
view.scene?.rootNode.addChildNode(arcRootNode)
view.scene?.rootNode.addChildNode(currentSunNode)
buildScene()
@@ -225,7 +223,7 @@ struct SimulatorSunOverlayView: UIViewRepresentable {
private func makeLineNode(through positions: [SCNVector3], color: UIColor) -> SCNNode {
guard positions.count >= 2 else { return SCNNode() }
var vertices: [SCNVector3] = positions
let vertices: [SCNVector3] = positions
var indices: [Int32] = []
for i in 0..<(vertices.count - 1) {
indices.append(Int32(i))

View File

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

Some files were not shown because too many files have changed in this diff Show More