feat: Ipad app features and Dream Weaver for Velocity WebOS
This commit is contained in:
565
.Agent Context/Codebase Analysis v1.1.md
Normal file
565
.Agent Context/Codebase Analysis v1.1.md
Normal 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
63
.github/workflows/production-readiness.yml
vendored
Normal file
63
.github/workflows/production-readiness.yml
vendored
Normal 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
|
||||
|
||||
26
app/dist/index.html
vendored
26
app/dist/index.html
vendored
@@ -1,13 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Velocity WebOS</title>
|
||||
<script type="module" crossorigin src="./assets/index-Bj2Xa_13.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-W2SBxMnB.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
</body>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Velocity WebOS</title>
|
||||
<script type="module" crossorigin src="./assets/index-C0KOan5Q.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-CrH2wIGN.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
2
app/node_modules/.tmp/tsconfig.app.tsbuildinfo
generated
vendored
2
app/node_modules/.tmp/tsconfig.app.tsbuildinfo
generated
vendored
@@ -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"}
|
||||
12
app/node_modules/.vite/deps/@radix-ui_react-avatar.js
generated
vendored
12
app/node_modules/.vite/deps/@radix-ui_react-avatar.js
generated
vendored
@@ -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";
|
||||
|
||||
6
app/node_modules/.vite/deps/@radix-ui_react-dropdown-menu.js
generated
vendored
6
app/node_modules/.vite/deps/@radix-ui_react-dropdown-menu.js
generated
vendored
@@ -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";
|
||||
|
||||
18
app/node_modules/.vite/deps/@react-three_drei.js
generated
vendored
18
app/node_modules/.vite/deps/@react-three_drei.js
generated
vendored
@@ -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";
|
||||
|
||||
88
app/node_modules/.vite/deps/_metadata.json
generated
vendored
88
app/node_modules/.vite/deps/_metadata.json
generated
vendored
@@ -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"
|
||||
}
|
||||
|
||||
6
app/node_modules/.vite/deps/recharts.js
generated
vendored
6
app/node_modules/.vite/deps/recharts.js
generated
vendored
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
524
app/src/components/modules/CatalystDreamWeaverTab.tsx
Normal file
524
app/src/components/modules/CatalystDreamWeaverTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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<{
|
||||
|
||||
197
app/src/lib/dreamWeaverApi.ts
Normal file
197
app/src/lib/dreamWeaverApi.ts
Normal 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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
37
app/src/lib/velocitySession.ts
Normal file
37
app/src/lib/velocitySession.ts
Normal 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;
|
||||
}
|
||||
@@ -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 : '';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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()}
|
||||
|
||||
24
backend/api/routes_observability.py
Normal file
24
backend/api/routes_observability.py
Normal 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),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
105
backend/auth/routes.py
Normal 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
123
backend/auth/service.py
Normal 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
|
||||
]
|
||||
45
backend/auth/user_directory.py
Normal file
45
backend/auth/user_directory.py
Normal 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)
|
||||
88
backend/crm/canonical_schema.py
Normal file
88
backend/crm/canonical_schema.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
173
backend/main.py
173
backend/main.py
@@ -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(),
|
||||
}
|
||||
|
||||
|
||||
2
backend/migrations/__init__.py
Normal file
2
backend/migrations/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Velocity backend migration utilities."""
|
||||
|
||||
102
backend/migrations/runner.py
Normal file
102
backend/migrations/runner.py
Normal 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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -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
103
backend/observability.py
Normal 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]
|
||||
|
||||
@@ -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')),
|
||||
|
||||
850
backend/scripts/seed_ipad_investor_demo.py
Normal file
850
backend/scripts/seed_ipad_investor_demo.py
Normal 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())
|
||||
@@ -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}
|
||||
"""
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
212
backend/tests/test_auth_tenant_contract.py
Normal file
212
backend/tests/test_auth_tenant_contract.py
Normal 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)
|
||||
162
backend/tests/test_canonical_crm_auth.py
Normal file
162
backend/tests/test_canonical_crm_auth.py
Normal 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"
|
||||
517
backend/tests/test_canonical_crm_tenant_scoping.py
Normal file
517
backend/tests/test_canonical_crm_tenant_scoping.py
Normal 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."
|
||||
@@ -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
|
||||
|
||||
30
backend/tests/test_dream_weaver_gateway_auth.py
Normal file
30
backend/tests/test_dream_weaver_gateway_auth.py
Normal 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
|
||||
238
backend/tests/test_legacy_crm_canonical_bridge.py
Normal file
238
backend/tests/test_legacy_crm_canonical_bridge.py
Normal 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"]
|
||||
243
backend/tests/test_legacy_crm_write_bridge.py
Normal file
243
backend/tests/test_legacy_crm_write_bridge.py
Normal 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]
|
||||
40
backend/tests/test_migrations_and_observability.py
Normal file
40
backend/tests/test_migrations_and_observability.py
Normal 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"
|
||||
|
||||
470
backend/tests/test_surface_route_tenant_scoping.py
Normal file
470
backend/tests/test_surface_route_tenant_scoping.py
Normal 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"] == []
|
||||
@@ -19,12 +19,13 @@ import asyncio, json, time, uuid, io, sys, os, logging, traceback
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
|
||||
import httpx
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, UploadFile, File, HTTPException, Form, BackgroundTasks
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
import httpx
|
||||
import uvicorn
|
||||
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"
|
||||
@@ -38,10 +39,48 @@ except ImportError:
|
||||
logging.warning("prompt_expander not found — LLM expansion disabled")
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
logger = logging.getLogger("DreamWeaverGateway")
|
||||
|
||||
COMFY = "http://127.0.0.1:8118"
|
||||
COMFY_ROOT = "/opt/dlami/nvme/ComfyUI"
|
||||
logger = logging.getLogger("DreamWeaverGateway")
|
||||
|
||||
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",
|
||||
@@ -51,7 +90,13 @@ app = FastAPI(
|
||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
||||
|
||||
# In-memory job store (swap for Redis in production)
|
||||
jobs: dict = {}
|
||||
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 ──────────────────────────────────────────────────────────────────
|
||||
@@ -73,8 +118,8 @@ class ExpandResponse(BaseModel):
|
||||
|
||||
|
||||
# ─── ComfyUI helpers ──────────────────────────────────────────────────────────
|
||||
async def upload_to_comfy(data: bytes, filename: str) -> str:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
async def upload_to_comfy(data: bytes, filename: str) -> str:
|
||||
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"}},
|
||||
"1": {"class_type": "CheckpointLoaderSimple",
|
||||
"inputs": {"ckpt_name": ckpt_name}},
|
||||
"2": {"class_type": "LoadImage",
|
||||
"inputs": {"image": img_name, "upload": "image"}},
|
||||
"3": {"class_type": "CLIPTextEncode", # Positive prompt
|
||||
@@ -114,17 +159,23 @@ def build_workflow(img_name: str, expanded: "ExpandedPrompt") -> dict:
|
||||
}
|
||||
|
||||
|
||||
async def queue_prompt(workflow: dict) -> str:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
r = await client.post(f"{COMFY}/prompt",
|
||||
json={"prompt": workflow, "client_id": str(uuid.uuid4())})
|
||||
r.raise_for_status()
|
||||
return r.json()["prompt_id"]
|
||||
async def queue_prompt(workflow: dict) -> str:
|
||||
async with comfy_client(timeout=30) as client:
|
||||
r = await client.post(f"{COMFY}/prompt",
|
||||
json={"prompt": workflow, "client_id": str(uuid.uuid4())})
|
||||
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 def poll_result(prompt_id: str, timeout: int = 300):
|
||||
start = time.time()
|
||||
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:
|
||||
@@ -149,23 +200,36 @@ async def background_poll(job_id: str, prompt_id: str):
|
||||
|
||||
# ─── Endpoints ───────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
comfy_ok = False
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as c:
|
||||
r = await c.get(f"{COMFY}/system_stats")
|
||||
comfy_ok = r.status_code == 200
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"status": "ok",
|
||||
"comfyui": comfy_ok,
|
||||
"gpu": "4x NVIDIA L4 (96GB VRAM)",
|
||||
"model": "RealVisXL V5.0 Lightning",
|
||||
"llm_expansion": LLM_AVAILABLE,
|
||||
"version": "2.0.0"
|
||||
}
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
comfy_ok = False
|
||||
checkpoints: list[str] = []
|
||||
try:
|
||||
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",
|
||||
"auth_required": GATEWAY_API_KEY is not None,
|
||||
"auth_scheme": "x-dream-weaver-api-key"
|
||||
}
|
||||
|
||||
|
||||
@app.get("/room-types")
|
||||
@@ -185,8 +249,9 @@ async def room_types():
|
||||
}
|
||||
|
||||
|
||||
@app.post("/dream-weaver/expand", response_model=ExpandResponse)
|
||||
async def expand_endpoint(req: ExpandRequest):
|
||||
@app.post("/dream-weaver/expand", response_model=ExpandResponse)
|
||||
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.
|
||||
@@ -228,8 +293,9 @@ async def expand_endpoint(req: ExpandRequest):
|
||||
|
||||
|
||||
@app.post("/dream-weaver")
|
||||
async def dream_weaver(
|
||||
image: UploadFile = File(...),
|
||||
async def dream_weaver(
|
||||
request: Request,
|
||||
image: UploadFile = File(...),
|
||||
# ── Dynamic keyword mode (new) ──
|
||||
keywords: str = Form(default=""), # comma-separated: "blue marble, gold, renaissance"
|
||||
room_type: str = Form(default="living_room"),
|
||||
@@ -239,7 +305,7 @@ async def dream_weaver(
|
||||
custom_negative: str = Form(default=""),
|
||||
denoise: float = Form(default=0.0), # 0.0 = use LLM recommendation
|
||||
cfg_scale: float = Form(default=0.0), # 0.0 = use LLM recommendation
|
||||
):
|
||||
):
|
||||
"""
|
||||
Submit a room photo for AI redesign using dynamic keyword → LLM → ComfyUI pipeline.
|
||||
|
||||
@@ -249,7 +315,8 @@ async def dream_weaver(
|
||||
|
||||
Returns job_id for async polling.
|
||||
"""
|
||||
job_id = str(uuid.uuid4())
|
||||
ensure_gateway_auth(request)
|
||||
job_id = str(uuid.uuid4())
|
||||
jobs[job_id] = {"status": "uploading", "created": time.time()}
|
||||
|
||||
try:
|
||||
@@ -312,9 +379,11 @@ async def dream_weaver(
|
||||
"room_type": room_type,
|
||||
})
|
||||
|
||||
# Submit workflow
|
||||
wf = build_workflow(comfy_name, expanded)
|
||||
prompt_id = await queue_prompt(wf)
|
||||
# Submit workflow
|
||||
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})
|
||||
|
||||
# Start background polling
|
||||
@@ -339,9 +408,10 @@ async def dream_weaver(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/dream-weaver/status/{job_id}")
|
||||
async def status(job_id: str):
|
||||
job = jobs.get(job_id)
|
||||
@app.get("/dream-weaver/status/{job_id}")
|
||||
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")
|
||||
result = {k: v for k, v in job.items() if k != "output"}
|
||||
@@ -351,15 +421,16 @@ async def status(job_id: str):
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/dream-weaver/result/{job_id}")
|
||||
async def result(job_id: str):
|
||||
job = jobs.get(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["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),
|
||||
@@ -369,19 +440,21 @@ async def result(job_id: str):
|
||||
|
||||
|
||||
@app.post("/dream-weaver/sync")
|
||||
async def dream_weaver_sync(
|
||||
image: UploadFile = File(...),
|
||||
async def dream_weaver_sync(
|
||||
request: Request,
|
||||
image: UploadFile = File(...),
|
||||
keywords: str = Form(default=""),
|
||||
room_type: str = Form(default="living_room"),
|
||||
additional_notes: str = Form(default=""),
|
||||
custom_positive: str = Form(default=""),
|
||||
custom_negative: str = Form(default=""),
|
||||
):
|
||||
):
|
||||
"""
|
||||
Blocking version — waits up to 120s and returns image bytes directly.
|
||||
Use for testing. Prefer async /dream-weaver for production.
|
||||
"""
|
||||
data = await image.read()
|
||||
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,
|
||||
|
||||
49
comfy_engine/scripts/gateway_auth.py
Normal file
49
comfy_engine/scripts/gateway_auth.py
Normal 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
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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 // 0–1
|
||||
var gpu: Double
|
||||
var memory: Double
|
||||
}
|
||||
|
||||
struct DashboardMetrics {
|
||||
var activeVisitors: Int
|
||||
var revenue: String
|
||||
var aiJobs: Int
|
||||
var dailyVisitors: Int
|
||||
var sentimentScore: Double // 0–100
|
||||
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 5–8M", 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 3–5M", 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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 FY2025–26").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 FY2025–26")
|
||||
}
|
||||
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)))
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
("5–15 min", 5, "up"),
|
||||
("15–30 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)))
|
||||
}
|
||||
}
|
||||
@@ -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
10
iOS/README.md
Normal 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.
|
||||
|
||||
@@ -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 */
|
||||
42
iOS/velocity-ipad/velocity/App/ConfigurationGateView.swift
Normal file
42
iOS/velocity-ipad/velocity/App/ConfigurationGateView.swift
Normal 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()
|
||||
}
|
||||
@@ -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 {
|
||||
NavigationSplitView(columnVisibility: .constant(.all)) {
|
||||
sidebarContent
|
||||
} detail: {
|
||||
detailContent
|
||||
Group {
|
||||
if session.isConfigured {
|
||||
NavigationSplitView(columnVisibility: .constant(.all)) {
|
||||
sidebarContent
|
||||
} detail: {
|
||||
detailContent
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
} else {
|
||||
ConfigurationGateView()
|
||||
}
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
302
iOS/velocity-ipad/velocity/Core/Config/AppConfig.swift
Normal file
302
iOS/velocity-ipad/velocity/Core/Config/AppConfig.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
204
iOS/velocity-ipad/velocity/Core/Config/SessionStore.swift
Normal file
204
iOS/velocity-ipad/velocity/Core/Config/SessionStore.swift
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
request.timeoutInterval = 30.0
|
||||
guard let (data, _) = try? await URLSession.shared.data(for: request),
|
||||
let json = try? JSONDecoder().decode(HealthResponse.self, from: data) else {
|
||||
do {
|
||||
var request = authorizedRequest(url: try resolvedURL(candidate: nil, fallbackPath: "/health"))
|
||||
request.timeoutInterval = 30.0
|
||||
|
||||
let (data, response) = try await urlSession.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, 200..<300 ~= http.statusCode else {
|
||||
return false
|
||||
}
|
||||
|
||||
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 {
|
||||
2165
iOS/velocity-ipad/velocity/Core/Networking/VelocityAPIClient.swift
Normal file
2165
iOS/velocity-ipad/velocity/Core/Networking/VelocityAPIClient.swift
Normal file
File diff suppressed because it is too large
Load Diff
815
iOS/velocity-ipad/velocity/Core/State/AppStore.swift
Normal file
815
iOS/velocity-ipad/velocity/Core/State/AppStore.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
1260
iOS/velocity-ipad/velocity/Features/Calendar/CalendarView.swift
Normal file
1260
iOS/velocity-ipad/velocity/Features/Calendar/CalendarView.swift
Normal file
File diff suppressed because it is too large
Load Diff
490
iOS/velocity-ipad/velocity/Features/Clients/ClientsView.swift
Normal file
490
iOS/velocity-ipad/velocity/Features/Clients/ClientsView.swift
Normal 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()
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 {
|
||||
467
iOS/velocity-ipad/velocity/Features/Imports/ImportsView.swift
Normal file
467
iOS/velocity-ipad/velocity/Features/Imports/ImportsView.swift
Normal 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()
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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: " · ")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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))
|
||||
@@ -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
Reference in New Issue
Block a user