Merge Conflicts (#41)
Some checks failed
Production Readiness / backend-contracts (push) Failing after 1m47s
Production Readiness / webos-typecheck (push) Successful in 1m57s
Production Readiness / ipad-parse (push) Successful in 1m32s

Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local>
Reviewed-on: #41
This commit was merged in pull request #41.
This commit is contained in:
2026-04-28 11:32:56 +05:30
parent 61258978e1
commit 7ee51543d9
158 changed files with 23889 additions and 87196 deletions

View File

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

File diff suppressed because it is too large Load Diff

View File

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

29
app/dist/index.html vendored
View File

@@ -1,13 +1,16 @@
<!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-BbE_azx6.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CILgAuxv.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>

View File

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

View File

@@ -1,18 +1,18 @@
"use client";
import {
require_shim
} from "./chunk-TXHHHGR3.js";
import {
useCallbackRef,
useLayoutEffect2
} from "./chunk-GRXJTWBV.js";
} from "./chunk-J4JAFMOP.js";
import {
require_react_dom
} from "./chunk-YLZ34CCM.js";
import {
require_shim
} from "./chunk-642Z5WD3.js";
} from "./chunk-YF4B4G2L.js";
import {
createSlot
} from "./chunk-5HUACAZ7.js";
import "./chunk-HPBHRBIF.js";
} from "./chunk-YWBEB5PG.js";
import "./chunk-2VUH7NEY.js";
import {
require_jsx_runtime
} from "./chunk-USXRE7Q2.js";

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,17 +0,0 @@
import {
Slot,
Slottable,
createSlot,
createSlottable
} from "./chunk-5HUACAZ7.js";
import "./chunk-HPBHRBIF.js";
import "./chunk-USXRE7Q2.js";
import "./chunk-ZNKPWGXJ.js";
import "./chunk-G3PMV62Z.js";
export {
Slot as Root,
Slot,
Slottable,
createSlot,
createSlottable
};

View File

@@ -1,7 +0,0 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,69 +0,0 @@
import {
Canvas,
_roots,
act,
addAfterEffect,
addEffect,
addTail,
advance,
applyProps,
buildGraph,
context,
createEvents,
createPointerEvents,
createPortal,
createRoot,
dispose,
extend,
flushGlobalEffects,
flushSync,
getRootState,
invalidate,
reconciler,
threeTypes,
unmountComponentAtNode,
useFrame,
useGraph,
useInstanceHandle,
useLoader,
useStore,
useThree
} from "./chunk-CSHY5MMV.js";
import "./chunk-LTNRPUSL.js";
import "./chunk-INS7YHTD.js";
import "./chunk-QURGMCZB.js";
import "./chunk-642Z5WD3.js";
import "./chunk-USXRE7Q2.js";
import "./chunk-ZNKPWGXJ.js";
import "./chunk-G3PMV62Z.js";
export {
Canvas,
threeTypes as ReactThreeFiber,
_roots,
act,
addAfterEffect,
addEffect,
addTail,
advance,
applyProps,
buildGraph,
context,
createEvents,
createPortal,
createRoot,
dispose,
createPointerEvents as events,
extend,
flushGlobalEffects,
flushSync,
getRootState,
invalidate,
reconciler,
unmountComponentAtNode,
useFrame,
useGraph,
useInstanceHandle,
useLoader,
useStore,
useThree
};

View File

@@ -1,7 +0,0 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}

View File

@@ -1,193 +1,196 @@
{
"hash": "9ed426b5",
"configHash": "6a55a817",
"lockfileHash": "cbf147e9",
"browserHash": "a13f5201",
"optimized": {
"react": {
"src": "../../react/index.js",
"file": "react.js",
"fileHash": "c178e920",
"needsInterop": true
},
"react-dom": {
"src": "../../react-dom/index.js",
"file": "react-dom.js",
"fileHash": "071b9320",
"needsInterop": true
},
"react/jsx-dev-runtime": {
"src": "../../react/jsx-dev-runtime.js",
"file": "react_jsx-dev-runtime.js",
"fileHash": "72ddf78c",
"needsInterop": true
},
"react/jsx-runtime": {
"src": "../../react/jsx-runtime.js",
"file": "react_jsx-runtime.js",
"fileHash": "14b8d385",
"needsInterop": true
},
"@radix-ui/react-avatar": {
"src": "../../@radix-ui/react-avatar/dist/index.mjs",
"file": "@radix-ui_react-avatar.js",
"fileHash": "590b7679",
"needsInterop": false
},
"@radix-ui/react-dropdown-menu": {
"src": "../../@radix-ui/react-dropdown-menu/dist/index.mjs",
"file": "@radix-ui_react-dropdown-menu.js",
"fileHash": "087b631e",
"needsInterop": false
},
"@radix-ui/react-slot": {
"src": "../../@radix-ui/react-slot/dist/index.mjs",
"file": "@radix-ui_react-slot.js",
"fileHash": "4e55412b",
"needsInterop": false
},
"@react-three/drei": {
"src": "../../@react-three/drei/index.js",
"file": "@react-three_drei.js",
"fileHash": "ba800aca",
"needsInterop": false
},
"@react-three/fiber": {
"src": "../../@react-three/fiber/dist/react-three-fiber.esm.js",
"file": "@react-three_fiber.js",
"fileHash": "12f23541",
"needsInterop": false
},
"class-variance-authority": {
"src": "../../class-variance-authority/dist/index.mjs",
"file": "class-variance-authority.js",
"fileHash": "0153428f",
"needsInterop": false
},
"clsx": {
"src": "../../clsx/dist/clsx.mjs",
"file": "clsx.js",
"fileHash": "99f068f1",
"needsInterop": false
},
"framer-motion": {
"src": "../../framer-motion/dist/es/index.mjs",
"file": "framer-motion.js",
"fileHash": "c1fc1ac2",
"needsInterop": false
},
"lucide-react": {
"src": "../../lucide-react/dist/esm/lucide-react.js",
"file": "lucide-react.js",
"fileHash": "4418176c",
"needsInterop": false
},
"react-dom/client": {
"src": "../../react-dom/client.js",
"file": "react-dom_client.js",
"fileHash": "8029f031",
"needsInterop": true
},
"react-router-dom": {
"src": "../../react-router-dom/dist/index.mjs",
"file": "react-router-dom.js",
"fileHash": "c673e5a0",
"needsInterop": false
},
"recharts": {
"src": "../../recharts/es6/index.js",
"file": "recharts.js",
"fileHash": "41235262",
"needsInterop": false
},
"sonner": {
"src": "../../sonner/dist/index.mjs",
"file": "sonner.js",
"fileHash": "c99e6320",
"needsInterop": false
},
"tailwind-merge": {
"src": "../../tailwind-merge/dist/bundle-mjs.mjs",
"file": "tailwind-merge.js",
"fileHash": "017ed736",
"needsInterop": false
},
"three": {
"src": "../../three/build/three.module.js",
"file": "three.js",
"fileHash": "8d6b5e64",
"needsInterop": false
},
"zustand": {
"src": "../../zustand/esm/index.mjs",
"file": "zustand.js",
"fileHash": "bcef7203",
"needsInterop": false
},
"zustand/middleware": {
"src": "../../zustand/esm/middleware.mjs",
"file": "zustand_middleware.js",
"fileHash": "1afe1817",
"needsInterop": false
}
},
"chunks": {
"hls-Q6LDPZPT": {
"file": "hls-Q6LDPZPT.js"
},
"chunk-QJTQF54Q": {
"file": "chunk-QJTQF54Q.js"
},
"chunk-XGWIEMTH": {
"file": "chunk-XGWIEMTH.js"
},
"chunk-OAEA5FZL": {
"file": "chunk-OAEA5FZL.js"
},
"chunk-2NWYL6R2": {
"file": "chunk-2NWYL6R2.js"
},
"chunk-H4GSM2WL": {
"file": "chunk-H4GSM2WL.js"
},
"chunk-U7P2NEEE": {
"file": "chunk-U7P2NEEE.js"
},
"chunk-GRXJTWBV": {
"file": "chunk-GRXJTWBV.js"
},
"chunk-YLZ34CCM": {
"file": "chunk-YLZ34CCM.js"
},
"chunk-CSHY5MMV": {
"file": "chunk-CSHY5MMV.js"
},
"chunk-LTNRPUSL": {
"file": "chunk-LTNRPUSL.js"
},
"chunk-INS7YHTD": {
"file": "chunk-INS7YHTD.js"
},
"chunk-QURGMCZB": {
"file": "chunk-QURGMCZB.js"
},
"chunk-642Z5WD3": {
"file": "chunk-642Z5WD3.js"
},
"chunk-5HUACAZ7": {
"file": "chunk-5HUACAZ7.js"
},
"chunk-HPBHRBIF": {
"file": "chunk-HPBHRBIF.js"
},
"chunk-USXRE7Q2": {
"file": "chunk-USXRE7Q2.js"
},
"chunk-ZNKPWGXJ": {
"file": "chunk-ZNKPWGXJ.js"
},
"chunk-G3PMV62Z": {
"file": "chunk-G3PMV62Z.js"
}
}
"hash": "d63ca5ca",
"configHash": "1dd3b956",
"lockfileHash": "db47663b",
"browserHash": "b8dcfecc",
"optimized": {
"react": {
"src": "../../react/index.js",
"file": "react.js",
"fileHash": "0c4ff044",
"needsInterop": true
},
"react-dom": {
"src": "../../react-dom/index.js",
"file": "react-dom.js",
"fileHash": "d9b3477a",
"needsInterop": true
},
"react/jsx-dev-runtime": {
"src": "../../react/jsx-dev-runtime.js",
"file": "react_jsx-dev-runtime.js",
"fileHash": "60584ffa",
"needsInterop": true
},
"react/jsx-runtime": {
"src": "../../react/jsx-runtime.js",
"file": "react_jsx-runtime.js",
"fileHash": "0909256b",
"needsInterop": true
},
"@radix-ui/react-avatar": {
"src": "../../@radix-ui/react-avatar/dist/index.mjs",
"file": "@radix-ui_react-avatar.js",
"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": "eef7ef00",
"needsInterop": false
},
"@radix-ui/react-slot": {
"src": "../../@radix-ui/react-slot/dist/index.mjs",
"file": "@radix-ui_react-slot.js",
"fileHash": "6745f8b7",
"needsInterop": false
},
"@react-three/drei": {
"src": "../../@react-three/drei/index.js",
"file": "@react-three_drei.js",
"fileHash": "62f4e280",
"needsInterop": false
},
"@react-three/fiber": {
"src": "../../@react-three/fiber/dist/react-three-fiber.esm.js",
"file": "@react-three_fiber.js",
"fileHash": "c4b868b0",
"needsInterop": false
},
"class-variance-authority": {
"src": "../../class-variance-authority/dist/index.mjs",
"file": "class-variance-authority.js",
"fileHash": "db4ee666",
"needsInterop": false
},
"clsx": {
"src": "../../clsx/dist/clsx.mjs",
"file": "clsx.js",
"fileHash": "0a67ca45",
"needsInterop": false
},
"framer-motion": {
"src": "../../framer-motion/dist/es/index.mjs",
"file": "framer-motion.js",
"fileHash": "9694d550",
"needsInterop": false
},
"lucide-react": {
"src": "../../lucide-react/dist/esm/lucide-react.js",
"file": "lucide-react.js",
"fileHash": "15d2dc31",
"needsInterop": false
},
"react-dom/client": {
"src": "../../react-dom/client.js",
"file": "react-dom_client.js",
"fileHash": "a8f9db58",
"needsInterop": true
},
"react-router-dom": {
"src": "../../react-router-dom/dist/index.mjs",
"file": "react-router-dom.js",
"fileHash": "3a519f93",
"needsInterop": false
},
"recharts": {
"src": "../../recharts/es6/index.js",
"file": "recharts.js",
"fileHash": "1cac0e9f",
"needsInterop": false
},
"sonner": {
"src": "../../sonner/dist/index.mjs",
"file": "sonner.js",
"fileHash": "1ad92981",
"needsInterop": false
},
"tailwind-merge": {
"src": "../../tailwind-merge/dist/bundle-mjs.mjs",
"file": "tailwind-merge.js",
"fileHash": "e2d07b44",
"needsInterop": false
},
"three": {
"src": "../../three/build/three.module.js",
"file": "three.js",
"fileHash": "09fb4882",
"needsInterop": false
},
"zustand": {
"src": "../../zustand/esm/index.mjs",
"file": "zustand.js",
"fileHash": "4607d0bf",
"needsInterop": false
},
"zustand/middleware": {
"src": "../../zustand/esm/middleware.mjs",
"file": "zustand_middleware.js",
"fileHash": "e4fd4342",
"needsInterop": false
}
},
"chunks": {
"hls-Q6LDPZPT": {
"file": "hls-Q6LDPZPT.js"
},
"chunk-EQCCHGRT": {
"file": "chunk-EQCCHGRT.js"
},
"chunk-7GZ4CI6Q": {
"file": "chunk-7GZ4CI6Q.js"
},
"chunk-5ESDTKMP": {
"file": "chunk-5ESDTKMP.js"
},
"chunk-U7P2NEEE": {
"file": "chunk-U7P2NEEE.js"
},
"chunk-GRXJTWBV": {
"file": "chunk-GRXJTWBV.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-LTNRPUSL": {
"file": "chunk-LTNRPUSL.js"
},
"chunk-TXHHHGR3": {
"file": "chunk-TXHHHGR3.js"
},
"chunk-J4JAFMOP": {
"file": "chunk-J4JAFMOP.js"
},
"chunk-YF4B4G2L": {
"file": "chunk-YF4B4G2L.js"
},
"chunk-YWBEB5PG": {
"file": "chunk-YWBEB5PG.js"
},
"chunk-2VUH7NEY": {
"file": "chunk-2VUH7NEY.js"
},
"chunk-2YVA4HRZ": {
"file": "chunk-2YVA4HRZ.js"
},
"chunk-WUR7D6NS": {
"file": "chunk-WUR7D6NS.js"
},
"chunk-U7P2NEEE": {
"file": "chunk-U7P2NEEE.js"
},
"chunk-G3PMV62Z": {
"file": "chunk-G3PMV62Z.js"
}
}
}

View File

@@ -1,35 +0,0 @@
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __commonJS = (cb, mod) => function __require() {
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
};
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
export {
__commonJS,
__export,
__toESM
};

View File

@@ -1,7 +0,0 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,21 +0,0 @@
// node_modules/clsx/dist/clsx.mjs
function r(e) {
var t, f, n = "";
if ("string" == typeof e || "number" == typeof e) n += e;
else if ("object" == typeof e) if (Array.isArray(e)) {
var o = e.length;
for (t = 0; t < o; t++) e[t] && (f = r(e[t])) && (n && (n += " "), n += f);
} else for (f in e) e[f] && (n && (n += " "), n += f);
return n;
}
function clsx() {
for (var e, t, f = 0, n = "", o = arguments.length; f < o; f++) (e = arguments[f]) && (t = r(e)) && (n && (n += " "), n += t);
return n;
}
var clsx_default = clsx;
export {
clsx,
clsx_default
};
//# sourceMappingURL=chunk-U7P2NEEE.js.map

View File

@@ -1,7 +0,0 @@
{
"version": 3,
"sources": ["../../clsx/dist/clsx.mjs"],
"sourcesContent": ["function r(e){var t,f,n=\"\";if(\"string\"==typeof e||\"number\"==typeof e)n+=e;else if(\"object\"==typeof e)if(Array.isArray(e)){var o=e.length;for(t=0;t<o;t++)e[t]&&(f=r(e[t]))&&(n&&(n+=\" \"),n+=f)}else for(f in e)e[f]&&(n&&(n+=\" \"),n+=f);return n}export function clsx(){for(var e,t,f=0,n=\"\",o=arguments.length;f<o;f++)(e=arguments[f])&&(t=r(e))&&(n&&(n+=\" \"),n+=t);return n}export default clsx;"],
"mappings": ";AAAA,SAAS,EAAE,GAAE;AAAC,MAAI,GAAE,GAAE,IAAE;AAAG,MAAG,YAAU,OAAO,KAAG,YAAU,OAAO,EAAE,MAAG;AAAA,WAAU,YAAU,OAAO,EAAE,KAAG,MAAM,QAAQ,CAAC,GAAE;AAAC,QAAI,IAAE,EAAE;AAAO,SAAI,IAAE,GAAE,IAAE,GAAE,IAAI,GAAE,CAAC,MAAI,IAAE,EAAE,EAAE,CAAC,CAAC,OAAK,MAAI,KAAG,MAAK,KAAG;AAAA,EAAE,MAAM,MAAI,KAAK,EAAE,GAAE,CAAC,MAAI,MAAI,KAAG,MAAK,KAAG;AAAG,SAAO;AAAC;AAAQ,SAAS,OAAM;AAAC,WAAQ,GAAE,GAAE,IAAE,GAAE,IAAE,IAAG,IAAE,UAAU,QAAO,IAAE,GAAE,IAAI,EAAC,IAAE,UAAU,CAAC,OAAK,IAAE,EAAE,CAAC,OAAK,MAAI,KAAG,MAAK,KAAG;AAAG,SAAO;AAAC;AAAC,IAAO,eAAQ;",
"names": []
}

View File

@@ -1,51 +0,0 @@
import {
clsx
} from "./chunk-U7P2NEEE.js";
import "./chunk-G3PMV62Z.js";
// node_modules/class-variance-authority/dist/index.mjs
var falsyToString = (value) => typeof value === "boolean" ? `${value}` : value === 0 ? "0" : value;
var cx = clsx;
var cva = (base, config) => (props) => {
var _config_compoundVariants;
if ((config === null || config === void 0 ? void 0 : config.variants) == null) return cx(base, props === null || props === void 0 ? void 0 : props.class, props === null || props === void 0 ? void 0 : props.className);
const { variants, defaultVariants } = config;
const getVariantClassNames = Object.keys(variants).map((variant) => {
const variantProp = props === null || props === void 0 ? void 0 : props[variant];
const defaultVariantProp = defaultVariants === null || defaultVariants === void 0 ? void 0 : defaultVariants[variant];
if (variantProp === null) return null;
const variantKey = falsyToString(variantProp) || falsyToString(defaultVariantProp);
return variants[variant][variantKey];
});
const propsWithoutUndefined = props && Object.entries(props).reduce((acc, param) => {
let [key, value] = param;
if (value === void 0) {
return acc;
}
acc[key] = value;
return acc;
}, {});
const getCompoundVariantClassNames = config === null || config === void 0 ? void 0 : (_config_compoundVariants = config.compoundVariants) === null || _config_compoundVariants === void 0 ? void 0 : _config_compoundVariants.reduce((acc, param) => {
let { class: cvClass, className: cvClassName, ...compoundVariantOptions } = param;
return Object.entries(compoundVariantOptions).every((param2) => {
let [key, value] = param2;
return Array.isArray(value) ? value.includes({
...defaultVariants,
...propsWithoutUndefined
}[key]) : {
...defaultVariants,
...propsWithoutUndefined
}[key] === value;
}) ? [
...acc,
cvClass,
cvClassName
] : acc;
}, []);
return cx(base, getVariantClassNames, getCompoundVariantClassNames, props === null || props === void 0 ? void 0 : props.class, props === null || props === void 0 ? void 0 : props.className);
};
export {
cva,
cx
};
//# sourceMappingURL=class-variance-authority.js.map

View File

@@ -1,7 +0,0 @@
{
"version": 3,
"sources": ["../../class-variance-authority/dist/index.mjs"],
"sourcesContent": ["/**\r\n * Copyright 2022 Joe Bell. All rights reserved.\r\n *\r\n * This file is licensed to you under the Apache License, Version 2.0\r\n * (the \"License\"); you may not use this file except in compliance with the\r\n * License. You may obtain a copy of the License at\r\n *\r\n * http://www.apache.org/licenses/LICENSE-2.0\r\n *\r\n * Unless required by applicable law or agreed to in writing, software\r\n * distributed under the License is distributed on an \"AS IS\" BASIS, WITHOUT\r\n * WARRANTIES OR REPRESENTATIONS OF ANY KIND, either express or implied. See the\r\n * License for the specific language governing permissions and limitations under\r\n * the License.\r\n */ import { clsx } from \"clsx\";\r\nconst falsyToString = (value)=>typeof value === \"boolean\" ? `${value}` : value === 0 ? \"0\" : value;\r\nexport const cx = clsx;\r\nexport const cva = (base, config)=>(props)=>{\r\n var _config_compoundVariants;\r\n if ((config === null || config === void 0 ? void 0 : config.variants) == null) return cx(base, props === null || props === void 0 ? void 0 : props.class, props === null || props === void 0 ? void 0 : props.className);\r\n const { variants, defaultVariants } = config;\r\n const getVariantClassNames = Object.keys(variants).map((variant)=>{\r\n const variantProp = props === null || props === void 0 ? void 0 : props[variant];\r\n const defaultVariantProp = defaultVariants === null || defaultVariants === void 0 ? void 0 : defaultVariants[variant];\r\n if (variantProp === null) return null;\r\n const variantKey = falsyToString(variantProp) || falsyToString(defaultVariantProp);\r\n return variants[variant][variantKey];\r\n });\r\n const propsWithoutUndefined = props && Object.entries(props).reduce((acc, param)=>{\r\n let [key, value] = param;\r\n if (value === undefined) {\r\n return acc;\r\n }\r\n acc[key] = value;\r\n return acc;\r\n }, {});\r\n const getCompoundVariantClassNames = config === null || config === void 0 ? void 0 : (_config_compoundVariants = config.compoundVariants) === null || _config_compoundVariants === void 0 ? void 0 : _config_compoundVariants.reduce((acc, param)=>{\r\n let { class: cvClass, className: cvClassName, ...compoundVariantOptions } = param;\r\n return Object.entries(compoundVariantOptions).every((param)=>{\r\n let [key, value] = param;\r\n return Array.isArray(value) ? value.includes({\r\n ...defaultVariants,\r\n ...propsWithoutUndefined\r\n }[key]) : ({\r\n ...defaultVariants,\r\n ...propsWithoutUndefined\r\n })[key] === value;\r\n }) ? [\r\n ...acc,\r\n cvClass,\r\n cvClassName\r\n ] : acc;\r\n }, []);\r\n return cx(base, getVariantClassNames, getCompoundVariantClassNames, props === null || props === void 0 ? void 0 : props.class, props === null || props === void 0 ? void 0 : props.className);\r\n };\r\n\r\n"],
"mappings": ";;;;;;AAeA,IAAM,gBAAgB,CAAC,UAAQ,OAAO,UAAU,YAAY,GAAG,KAAK,KAAK,UAAU,IAAI,MAAM;AACtF,IAAM,KAAK;AACX,IAAM,MAAM,CAAC,MAAM,WAAS,CAAC,UAAQ;AACpC,MAAI;AACJ,OAAK,WAAW,QAAQ,WAAW,SAAS,SAAS,OAAO,aAAa,KAAM,QAAO,GAAG,MAAM,UAAU,QAAQ,UAAU,SAAS,SAAS,MAAM,OAAO,UAAU,QAAQ,UAAU,SAAS,SAAS,MAAM,SAAS;AACvN,QAAM,EAAE,UAAU,gBAAgB,IAAI;AACtC,QAAM,uBAAuB,OAAO,KAAK,QAAQ,EAAE,IAAI,CAAC,YAAU;AAC9D,UAAM,cAAc,UAAU,QAAQ,UAAU,SAAS,SAAS,MAAM,OAAO;AAC/E,UAAM,qBAAqB,oBAAoB,QAAQ,oBAAoB,SAAS,SAAS,gBAAgB,OAAO;AACpH,QAAI,gBAAgB,KAAM,QAAO;AACjC,UAAM,aAAa,cAAc,WAAW,KAAK,cAAc,kBAAkB;AACjF,WAAO,SAAS,OAAO,EAAE,UAAU;AAAA,EACvC,CAAC;AACD,QAAM,wBAAwB,SAAS,OAAO,QAAQ,KAAK,EAAE,OAAO,CAAC,KAAK,UAAQ;AAC9E,QAAI,CAAC,KAAK,KAAK,IAAI;AACnB,QAAI,UAAU,QAAW;AACrB,aAAO;AAAA,IACX;AACA,QAAI,GAAG,IAAI;AACX,WAAO;AAAA,EACX,GAAG,CAAC,CAAC;AACL,QAAM,+BAA+B,WAAW,QAAQ,WAAW,SAAS,UAAU,2BAA2B,OAAO,sBAAsB,QAAQ,6BAA6B,SAAS,SAAS,yBAAyB,OAAO,CAAC,KAAK,UAAQ;AAC/O,QAAI,EAAE,OAAO,SAAS,WAAW,aAAa,GAAG,uBAAuB,IAAI;AAC5E,WAAO,OAAO,QAAQ,sBAAsB,EAAE,MAAM,CAACA,WAAQ;AACzD,UAAI,CAAC,KAAK,KAAK,IAAIA;AACnB,aAAO,MAAM,QAAQ,KAAK,IAAI,MAAM,SAAS;AAAA,QACzC,GAAG;AAAA,QACH,GAAG;AAAA,MACP,EAAE,GAAG,CAAC,IAAK;AAAA,QACP,GAAG;AAAA,QACH,GAAG;AAAA,MACP,EAAG,GAAG,MAAM;AAAA,IAChB,CAAC,IAAI;AAAA,MACD,GAAG;AAAA,MACH;AAAA,MACA;AAAA,IACJ,IAAI;AAAA,EACR,GAAG,CAAC,CAAC;AACL,SAAO,GAAG,MAAM,sBAAsB,8BAA8B,UAAU,QAAQ,UAAU,SAAS,SAAS,MAAM,OAAO,UAAU,QAAQ,UAAU,SAAS,SAAS,MAAM,SAAS;AAChM;",
"names": ["param"]
}

View File

@@ -1,9 +0,0 @@
import {
clsx,
clsx_default
} from "./chunk-U7P2NEEE.js";
import "./chunk-G3PMV62Z.js";
export {
clsx,
clsx_default as default
};

View File

@@ -1,7 +0,0 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,98 +0,0 @@
import {
AbrController,
AttrList,
AudioStreamController,
AudioTrackController,
BasePlaylistController,
BaseSegment,
BaseStreamController,
BufferController,
CMCDController,
CapLevelController,
ChunkMetadata,
ContentSteeringController,
Cues,
DateRange,
EMEController,
ErrorActionFlags,
ErrorController,
ErrorDetails,
ErrorTypes,
Events,
FPSController,
FetchLoader,
Fragment,
Hls,
HlsSkip,
HlsUrlParameters,
KeySystemFormats,
KeySystems,
Level,
LevelDetails,
LevelKey,
LoadStats,
M3U8Parser,
MetadataSchema,
NetworkErrorAction,
Part,
PlaylistLevelType,
SubtitleStreamController,
SubtitleTrackController,
TimelineController,
XhrLoader,
fetchSupported,
getMediaSource,
isMSESupported,
isSupported,
requestMediaKeySystemAccess
} from "./chunk-OAEA5FZL.js";
import "./chunk-G3PMV62Z.js";
export {
AbrController,
AttrList,
AudioStreamController,
AudioTrackController,
BasePlaylistController,
BaseSegment,
BaseStreamController,
BufferController,
CMCDController,
CapLevelController,
ChunkMetadata,
ContentSteeringController,
Cues,
DateRange,
EMEController,
ErrorActionFlags,
ErrorController,
ErrorDetails,
ErrorTypes,
Events,
FPSController,
FetchLoader,
Fragment,
Hls,
HlsSkip,
HlsUrlParameters,
KeySystemFormats,
KeySystems,
Level,
LevelDetails,
LevelKey,
LoadStats,
M3U8Parser,
MetadataSchema,
NetworkErrorAction,
Part,
PlaylistLevelType,
SubtitleStreamController,
SubtitleTrackController,
TimelineController,
XhrLoader,
Hls as default,
fetchSupported,
getMediaSource,
isMSESupported,
isSupported,
requestMediaKeySystemAccess
};

View File

@@ -1,7 +0,0 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,262 @@
import {
require_react_dom
} from "./chunk-YLZ34CCM.js";
import "./chunk-ZNKPWGXJ.js";
import "./chunk-G3PMV62Z.js";
__commonJS,
require_react
} from "./chunk-E7O7WYRO.js";
// node_modules/react-dom/cjs/react-dom.development.js
var require_react_dom_development = __commonJS({
"node_modules/react-dom/cjs/react-dom.development.js"(exports) {
"use strict";
(function() {
function noop() {
}
function testStringCoercion(value) {
return "" + value;
}
function createPortal$1(children, containerInfo, implementation) {
var key = 3 < arguments.length && void 0 !== arguments[3] ? arguments[3] : null;
try {
testStringCoercion(key);
var JSCompiler_inline_result = false;
} catch (e) {
JSCompiler_inline_result = true;
}
JSCompiler_inline_result && (console.error(
"The provided key is an unsupported type %s. This value must be coerced to a string before using it here.",
"function" === typeof Symbol && Symbol.toStringTag && key[Symbol.toStringTag] || key.constructor.name || "Object"
), testStringCoercion(key));
return {
$$typeof: REACT_PORTAL_TYPE,
key: null == key ? null : "" + key,
children,
containerInfo,
implementation
};
}
function getCrossOriginStringAs(as, input) {
if ("font" === as) return "";
if ("string" === typeof input)
return "use-credentials" === input ? input : "";
}
function getValueDescriptorExpectingObjectForWarning(thing) {
return null === thing ? "`null`" : void 0 === thing ? "`undefined`" : "" === thing ? "an empty string" : 'something with type "' + typeof thing + '"';
}
function getValueDescriptorExpectingEnumForWarning(thing) {
return null === thing ? "`null`" : void 0 === thing ? "`undefined`" : "" === thing ? "an empty string" : "string" === typeof thing ? JSON.stringify(thing) : "number" === typeof thing ? "`" + thing + "`" : 'something with type "' + typeof thing + '"';
}
function resolveDispatcher() {
var dispatcher = ReactSharedInternals.H;
null === dispatcher && console.error(
"Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons:\n1. You might have mismatching versions of React and the renderer (such as React DOM)\n2. You might be breaking the Rules of Hooks\n3. You might have more than one copy of React in the same app\nSee https://react.dev/link/invalid-hook-call for tips about how to debug and fix this problem."
);
return dispatcher;
}
"undefined" !== typeof __REACT_DEVTOOLS_GLOBAL_HOOK__ && "function" === typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart && __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart(Error());
var React = require_react(), Internals = {
d: {
f: noop,
r: function() {
throw Error(
"Invalid form element. requestFormReset must be passed a form that was rendered by React."
);
},
D: noop,
C: noop,
L: noop,
m: noop,
X: noop,
S: noop,
M: noop
},
p: 0,
findDOMNode: null
}, REACT_PORTAL_TYPE = /* @__PURE__ */ Symbol.for("react.portal"), ReactSharedInternals = React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
"function" === typeof Map && null != Map.prototype && "function" === typeof Map.prototype.forEach && "function" === typeof Set && null != Set.prototype && "function" === typeof Set.prototype.clear && "function" === typeof Set.prototype.forEach || console.error(
"React depends on Map and Set built-in types. Make sure that you load a polyfill in older browsers. https://reactjs.org/link/react-polyfills"
);
exports.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE = Internals;
exports.createPortal = function(children, container) {
var key = 2 < arguments.length && void 0 !== arguments[2] ? arguments[2] : null;
if (!container || 1 !== container.nodeType && 9 !== container.nodeType && 11 !== container.nodeType)
throw Error("Target container is not a DOM element.");
return createPortal$1(children, container, null, key);
};
exports.flushSync = function(fn) {
var previousTransition = ReactSharedInternals.T, previousUpdatePriority = Internals.p;
try {
if (ReactSharedInternals.T = null, Internals.p = 2, fn)
return fn();
} finally {
ReactSharedInternals.T = previousTransition, Internals.p = previousUpdatePriority, Internals.d.f() && console.error(
"flushSync was called from inside a lifecycle method. React cannot flush when React is already rendering. Consider moving this call to a scheduler task or micro task."
);
}
};
exports.preconnect = function(href, options) {
"string" === typeof href && href ? null != options && "object" !== typeof options ? console.error(
"ReactDOM.preconnect(): Expected the `options` argument (second) to be an object but encountered %s instead. The only supported option at this time is `crossOrigin` which accepts a string.",
getValueDescriptorExpectingEnumForWarning(options)
) : null != options && "string" !== typeof options.crossOrigin && console.error(
"ReactDOM.preconnect(): Expected the `crossOrigin` option (second argument) to be a string but encountered %s instead. Try removing this option or passing a string value instead.",
getValueDescriptorExpectingObjectForWarning(options.crossOrigin)
) : console.error(
"ReactDOM.preconnect(): Expected the `href` argument (first) to be a non-empty string but encountered %s instead.",
getValueDescriptorExpectingObjectForWarning(href)
);
"string" === typeof href && (options ? (options = options.crossOrigin, options = "string" === typeof options ? "use-credentials" === options ? options : "" : void 0) : options = null, Internals.d.C(href, options));
};
exports.prefetchDNS = function(href) {
if ("string" !== typeof href || !href)
console.error(
"ReactDOM.prefetchDNS(): Expected the `href` argument (first) to be a non-empty string but encountered %s instead.",
getValueDescriptorExpectingObjectForWarning(href)
);
else if (1 < arguments.length) {
var options = arguments[1];
"object" === typeof options && options.hasOwnProperty("crossOrigin") ? console.error(
"ReactDOM.prefetchDNS(): Expected only one argument, `href`, but encountered %s as a second argument instead. This argument is reserved for future options and is currently disallowed. It looks like the you are attempting to set a crossOrigin property for this DNS lookup hint. Browsers do not perform DNS queries using CORS and setting this attribute on the resource hint has no effect. Try calling ReactDOM.prefetchDNS() with just a single string argument, `href`.",
getValueDescriptorExpectingEnumForWarning(options)
) : console.error(
"ReactDOM.prefetchDNS(): Expected only one argument, `href`, but encountered %s as a second argument instead. This argument is reserved for future options and is currently disallowed. Try calling ReactDOM.prefetchDNS() with just a single string argument, `href`.",
getValueDescriptorExpectingEnumForWarning(options)
);
}
"string" === typeof href && Internals.d.D(href);
};
exports.preinit = function(href, options) {
"string" === typeof href && href ? null == options || "object" !== typeof options ? console.error(
"ReactDOM.preinit(): Expected the `options` argument (second) to be an object with an `as` property describing the type of resource to be preinitialized but encountered %s instead.",
getValueDescriptorExpectingEnumForWarning(options)
) : "style" !== options.as && "script" !== options.as && console.error(
'ReactDOM.preinit(): Expected the `as` property in the `options` argument (second) to contain a valid value describing the type of resource to be preinitialized but encountered %s instead. Valid values for `as` are "style" and "script".',
getValueDescriptorExpectingEnumForWarning(options.as)
) : console.error(
"ReactDOM.preinit(): Expected the `href` argument (first) to be a non-empty string but encountered %s instead.",
getValueDescriptorExpectingObjectForWarning(href)
);
if ("string" === typeof href && options && "string" === typeof options.as) {
var as = options.as, crossOrigin = getCrossOriginStringAs(as, options.crossOrigin), integrity = "string" === typeof options.integrity ? options.integrity : void 0, fetchPriority = "string" === typeof options.fetchPriority ? options.fetchPriority : void 0;
"style" === as ? Internals.d.S(
href,
"string" === typeof options.precedence ? options.precedence : void 0,
{
crossOrigin,
integrity,
fetchPriority
}
) : "script" === as && Internals.d.X(href, {
crossOrigin,
integrity,
fetchPriority,
nonce: "string" === typeof options.nonce ? options.nonce : void 0
});
}
};
exports.preinitModule = function(href, options) {
var encountered = "";
"string" === typeof href && href || (encountered += " The `href` argument encountered was " + getValueDescriptorExpectingObjectForWarning(href) + ".");
void 0 !== options && "object" !== typeof options ? encountered += " The `options` argument encountered was " + getValueDescriptorExpectingObjectForWarning(options) + "." : options && "as" in options && "script" !== options.as && (encountered += " The `as` option encountered was " + getValueDescriptorExpectingEnumForWarning(options.as) + ".");
if (encountered)
console.error(
"ReactDOM.preinitModule(): Expected up to two arguments, a non-empty `href` string and, optionally, an `options` object with a valid `as` property.%s",
encountered
);
else
switch (encountered = options && "string" === typeof options.as ? options.as : "script", encountered) {
case "script":
break;
default:
encountered = getValueDescriptorExpectingEnumForWarning(encountered), console.error(
'ReactDOM.preinitModule(): Currently the only supported "as" type for this function is "script" but received "%s" instead. This warning was generated for `href` "%s". In the future other module types will be supported, aligning with the import-attributes proposal. Learn more here: (https://github.com/tc39/proposal-import-attributes)',
encountered,
href
);
}
if ("string" === typeof href)
if ("object" === typeof options && null !== options) {
if (null == options.as || "script" === options.as)
encountered = getCrossOriginStringAs(
options.as,
options.crossOrigin
), Internals.d.M(href, {
crossOrigin: encountered,
integrity: "string" === typeof options.integrity ? options.integrity : void 0,
nonce: "string" === typeof options.nonce ? options.nonce : void 0
});
} else null == options && Internals.d.M(href);
};
exports.preload = function(href, options) {
var encountered = "";
"string" === typeof href && href || (encountered += " The `href` argument encountered was " + getValueDescriptorExpectingObjectForWarning(href) + ".");
null == options || "object" !== typeof options ? encountered += " The `options` argument encountered was " + getValueDescriptorExpectingObjectForWarning(options) + "." : "string" === typeof options.as && options.as || (encountered += " The `as` option encountered was " + getValueDescriptorExpectingObjectForWarning(options.as) + ".");
encountered && console.error(
'ReactDOM.preload(): Expected two arguments, a non-empty `href` string and an `options` object with an `as` property valid for a `<link rel="preload" as="..." />` tag.%s',
encountered
);
if ("string" === typeof href && "object" === typeof options && null !== options && "string" === typeof options.as) {
encountered = options.as;
var crossOrigin = getCrossOriginStringAs(
encountered,
options.crossOrigin
);
Internals.d.L(href, encountered, {
crossOrigin,
integrity: "string" === typeof options.integrity ? options.integrity : void 0,
nonce: "string" === typeof options.nonce ? options.nonce : void 0,
type: "string" === typeof options.type ? options.type : void 0,
fetchPriority: "string" === typeof options.fetchPriority ? options.fetchPriority : void 0,
referrerPolicy: "string" === typeof options.referrerPolicy ? options.referrerPolicy : void 0,
imageSrcSet: "string" === typeof options.imageSrcSet ? options.imageSrcSet : void 0,
imageSizes: "string" === typeof options.imageSizes ? options.imageSizes : void 0,
media: "string" === typeof options.media ? options.media : void 0
});
}
};
exports.preloadModule = function(href, options) {
var encountered = "";
"string" === typeof href && href || (encountered += " The `href` argument encountered was " + getValueDescriptorExpectingObjectForWarning(href) + ".");
void 0 !== options && "object" !== typeof options ? encountered += " The `options` argument encountered was " + getValueDescriptorExpectingObjectForWarning(options) + "." : options && "as" in options && "string" !== typeof options.as && (encountered += " The `as` option encountered was " + getValueDescriptorExpectingObjectForWarning(options.as) + ".");
encountered && console.error(
'ReactDOM.preloadModule(): Expected two arguments, a non-empty `href` string and, optionally, an `options` object with an `as` property valid for a `<link rel="modulepreload" as="..." />` tag.%s',
encountered
);
"string" === typeof href && (options ? (encountered = getCrossOriginStringAs(
options.as,
options.crossOrigin
), Internals.d.m(href, {
as: "string" === typeof options.as && "script" !== options.as ? options.as : void 0,
crossOrigin: encountered,
integrity: "string" === typeof options.integrity ? options.integrity : void 0
})) : Internals.d.m(href));
};
exports.requestFormReset = function(form) {
Internals.d.r(form);
};
exports.unstable_batchedUpdates = function(fn, a) {
return fn(a);
};
exports.useFormState = function(action, initialState, permalink) {
return resolveDispatcher().useFormState(action, initialState, permalink);
};
exports.useFormStatus = function() {
return resolveDispatcher().useHostTransitionStatus();
};
exports.version = "19.2.3";
"undefined" !== typeof __REACT_DEVTOOLS_GLOBAL_HOOK__ && "function" === typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop && __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop(Error());
})();
}
});
// node_modules/react-dom/index.js
var require_react_dom = __commonJS({
"node_modules/react-dom/index.js"(exports, module) {
if (false) {
checkDCE();
module.exports = null;
} else {
module.exports = require_react_dom_development();
}
}
});
export default require_react_dom();
//# sourceMappingURL=react-dom.js.map

File diff suppressed because one or more lines are too long

View File

@@ -1,8 +0,0 @@
import {
require_client
} from "./chunk-2NWYL6R2.js";
import "./chunk-YLZ34CCM.js";
import "./chunk-QURGMCZB.js";
import "./chunk-ZNKPWGXJ.js";
import "./chunk-G3PMV62Z.js";
export default require_client();

View File

@@ -1,7 +0,0 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}

View File

@@ -1,5 +1,4 @@
import {
require_react
} from "./chunk-ZNKPWGXJ.js";
import "./chunk-G3PMV62Z.js";
} from "./chunk-E7O7WYRO.js";
export default require_react();

View File

@@ -1,9 +1,7 @@
import {
__commonJS,
require_react
} from "./chunk-ZNKPWGXJ.js";
import {
__commonJS
} from "./chunk-G3PMV62Z.js";
} from "./chunk-E7O7WYRO.js";
// node_modules/react/cjs/react-jsx-dev-runtime.development.js
var require_react_jsx_dev_runtime_development = __commonJS({

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,274 @@
import {
require_jsx_runtime
} from "./chunk-USXRE7Q2.js";
import "./chunk-ZNKPWGXJ.js";
import "./chunk-G3PMV62Z.js";
__commonJS,
require_react
} from "./chunk-E7O7WYRO.js";
// node_modules/react/cjs/react-jsx-runtime.development.js
var require_react_jsx_runtime_development = __commonJS({
"node_modules/react/cjs/react-jsx-runtime.development.js"(exports) {
"use strict";
(function() {
function getComponentNameFromType(type) {
if (null == type) return null;
if ("function" === typeof type)
return type.$$typeof === REACT_CLIENT_REFERENCE ? null : type.displayName || type.name || null;
if ("string" === typeof type) return type;
switch (type) {
case REACT_FRAGMENT_TYPE:
return "Fragment";
case REACT_PROFILER_TYPE:
return "Profiler";
case REACT_STRICT_MODE_TYPE:
return "StrictMode";
case REACT_SUSPENSE_TYPE:
return "Suspense";
case REACT_SUSPENSE_LIST_TYPE:
return "SuspenseList";
case REACT_ACTIVITY_TYPE:
return "Activity";
}
if ("object" === typeof type)
switch ("number" === typeof type.tag && console.error(
"Received an unexpected object in getComponentNameFromType(). This is likely a bug in React. Please file an issue."
), type.$$typeof) {
case REACT_PORTAL_TYPE:
return "Portal";
case REACT_CONTEXT_TYPE:
return type.displayName || "Context";
case REACT_CONSUMER_TYPE:
return (type._context.displayName || "Context") + ".Consumer";
case REACT_FORWARD_REF_TYPE:
var innerType = type.render;
type = type.displayName;
type || (type = innerType.displayName || innerType.name || "", type = "" !== type ? "ForwardRef(" + type + ")" : "ForwardRef");
return type;
case REACT_MEMO_TYPE:
return innerType = type.displayName || null, null !== innerType ? innerType : getComponentNameFromType(type.type) || "Memo";
case REACT_LAZY_TYPE:
innerType = type._payload;
type = type._init;
try {
return getComponentNameFromType(type(innerType));
} catch (x) {
}
}
return null;
}
function testStringCoercion(value) {
return "" + value;
}
function checkKeyStringCoercion(value) {
try {
testStringCoercion(value);
var JSCompiler_inline_result = false;
} catch (e) {
JSCompiler_inline_result = true;
}
if (JSCompiler_inline_result) {
JSCompiler_inline_result = console;
var JSCompiler_temp_const = JSCompiler_inline_result.error;
var JSCompiler_inline_result$jscomp$0 = "function" === typeof Symbol && Symbol.toStringTag && value[Symbol.toStringTag] || value.constructor.name || "Object";
JSCompiler_temp_const.call(
JSCompiler_inline_result,
"The provided key is an unsupported type %s. This value must be coerced to a string before using it here.",
JSCompiler_inline_result$jscomp$0
);
return testStringCoercion(value);
}
}
function getTaskName(type) {
if (type === REACT_FRAGMENT_TYPE) return "<>";
if ("object" === typeof type && null !== type && type.$$typeof === REACT_LAZY_TYPE)
return "<...>";
try {
var name = getComponentNameFromType(type);
return name ? "<" + name + ">" : "<...>";
} catch (x) {
return "<...>";
}
}
function getOwner() {
var dispatcher = ReactSharedInternals.A;
return null === dispatcher ? null : dispatcher.getOwner();
}
function UnknownOwner() {
return Error("react-stack-top-frame");
}
function hasValidKey(config) {
if (hasOwnProperty.call(config, "key")) {
var getter = Object.getOwnPropertyDescriptor(config, "key").get;
if (getter && getter.isReactWarning) return false;
}
return void 0 !== config.key;
}
function defineKeyPropWarningGetter(props, displayName) {
function warnAboutAccessingKey() {
specialPropKeyWarningShown || (specialPropKeyWarningShown = true, console.error(
"%s: `key` is not a prop. Trying to access it will result in `undefined` being returned. If you need to access the same value within the child component, you should pass it as a different prop. (https://react.dev/link/special-props)",
displayName
));
}
warnAboutAccessingKey.isReactWarning = true;
Object.defineProperty(props, "key", {
get: warnAboutAccessingKey,
configurable: true
});
}
function elementRefGetterWithDeprecationWarning() {
var componentName = getComponentNameFromType(this.type);
didWarnAboutElementRef[componentName] || (didWarnAboutElementRef[componentName] = true, console.error(
"Accessing element.ref was removed in React 19. ref is now a regular prop. It will be removed from the JSX Element type in a future release."
));
componentName = this.props.ref;
return void 0 !== componentName ? componentName : null;
}
function ReactElement(type, key, props, owner, debugStack, debugTask) {
var refProp = props.ref;
type = {
$$typeof: REACT_ELEMENT_TYPE,
type,
key,
props,
_owner: owner
};
null !== (void 0 !== refProp ? refProp : null) ? Object.defineProperty(type, "ref", {
enumerable: false,
get: elementRefGetterWithDeprecationWarning
}) : Object.defineProperty(type, "ref", { enumerable: false, value: null });
type._store = {};
Object.defineProperty(type._store, "validated", {
configurable: false,
enumerable: false,
writable: true,
value: 0
});
Object.defineProperty(type, "_debugInfo", {
configurable: false,
enumerable: false,
writable: true,
value: null
});
Object.defineProperty(type, "_debugStack", {
configurable: false,
enumerable: false,
writable: true,
value: debugStack
});
Object.defineProperty(type, "_debugTask", {
configurable: false,
enumerable: false,
writable: true,
value: debugTask
});
Object.freeze && (Object.freeze(type.props), Object.freeze(type));
return type;
}
function jsxDEVImpl(type, config, maybeKey, isStaticChildren, debugStack, debugTask) {
var children = config.children;
if (void 0 !== children)
if (isStaticChildren)
if (isArrayImpl(children)) {
for (isStaticChildren = 0; isStaticChildren < children.length; isStaticChildren++)
validateChildKeys(children[isStaticChildren]);
Object.freeze && Object.freeze(children);
} else
console.error(
"React.jsx: Static children should always be an array. You are likely explicitly calling React.jsxs or React.jsxDEV. Use the Babel transform instead."
);
else validateChildKeys(children);
if (hasOwnProperty.call(config, "key")) {
children = getComponentNameFromType(type);
var keys = Object.keys(config).filter(function(k) {
return "key" !== k;
});
isStaticChildren = 0 < keys.length ? "{key: someKey, " + keys.join(": ..., ") + ": ...}" : "{key: someKey}";
didWarnAboutKeySpread[children + isStaticChildren] || (keys = 0 < keys.length ? "{" + keys.join(": ..., ") + ": ...}" : "{}", console.error(
'A props object containing a "key" prop is being spread into JSX:\n let props = %s;\n <%s {...props} />\nReact keys must be passed directly to JSX without using spread:\n let props = %s;\n <%s key={someKey} {...props} />',
isStaticChildren,
children,
keys,
children
), didWarnAboutKeySpread[children + isStaticChildren] = true);
}
children = null;
void 0 !== maybeKey && (checkKeyStringCoercion(maybeKey), children = "" + maybeKey);
hasValidKey(config) && (checkKeyStringCoercion(config.key), children = "" + config.key);
if ("key" in config) {
maybeKey = {};
for (var propName in config)
"key" !== propName && (maybeKey[propName] = config[propName]);
} else maybeKey = config;
children && defineKeyPropWarningGetter(
maybeKey,
"function" === typeof type ? type.displayName || type.name || "Unknown" : type
);
return ReactElement(
type,
children,
maybeKey,
getOwner(),
debugStack,
debugTask
);
}
function validateChildKeys(node) {
isValidElement(node) ? node._store && (node._store.validated = 1) : "object" === typeof node && null !== node && node.$$typeof === REACT_LAZY_TYPE && ("fulfilled" === node._payload.status ? isValidElement(node._payload.value) && node._payload.value._store && (node._payload.value._store.validated = 1) : node._store && (node._store.validated = 1));
}
function isValidElement(object) {
return "object" === typeof object && null !== object && object.$$typeof === REACT_ELEMENT_TYPE;
}
var React = require_react(), REACT_ELEMENT_TYPE = /* @__PURE__ */ Symbol.for("react.transitional.element"), REACT_PORTAL_TYPE = /* @__PURE__ */ Symbol.for("react.portal"), REACT_FRAGMENT_TYPE = /* @__PURE__ */ Symbol.for("react.fragment"), REACT_STRICT_MODE_TYPE = /* @__PURE__ */ Symbol.for("react.strict_mode"), REACT_PROFILER_TYPE = /* @__PURE__ */ Symbol.for("react.profiler"), REACT_CONSUMER_TYPE = /* @__PURE__ */ Symbol.for("react.consumer"), REACT_CONTEXT_TYPE = /* @__PURE__ */ Symbol.for("react.context"), REACT_FORWARD_REF_TYPE = /* @__PURE__ */ Symbol.for("react.forward_ref"), REACT_SUSPENSE_TYPE = /* @__PURE__ */ Symbol.for("react.suspense"), REACT_SUSPENSE_LIST_TYPE = /* @__PURE__ */ Symbol.for("react.suspense_list"), REACT_MEMO_TYPE = /* @__PURE__ */ Symbol.for("react.memo"), REACT_LAZY_TYPE = /* @__PURE__ */ Symbol.for("react.lazy"), REACT_ACTIVITY_TYPE = /* @__PURE__ */ Symbol.for("react.activity"), REACT_CLIENT_REFERENCE = /* @__PURE__ */ Symbol.for("react.client.reference"), ReactSharedInternals = React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE, hasOwnProperty = Object.prototype.hasOwnProperty, isArrayImpl = Array.isArray, createTask = console.createTask ? console.createTask : function() {
return null;
};
React = {
react_stack_bottom_frame: function(callStackForError) {
return callStackForError();
}
};
var specialPropKeyWarningShown;
var didWarnAboutElementRef = {};
var unknownOwnerDebugStack = React.react_stack_bottom_frame.bind(
React,
UnknownOwner
)();
var unknownOwnerDebugTask = createTask(getTaskName(UnknownOwner));
var didWarnAboutKeySpread = {};
exports.Fragment = REACT_FRAGMENT_TYPE;
exports.jsx = function(type, config, maybeKey) {
var trackActualOwner = 1e4 > ReactSharedInternals.recentlyCreatedOwnerStacks++;
return jsxDEVImpl(
type,
config,
maybeKey,
false,
trackActualOwner ? Error("react-stack-top-frame") : unknownOwnerDebugStack,
trackActualOwner ? createTask(getTaskName(type)) : unknownOwnerDebugTask
);
};
exports.jsxs = function(type, config, maybeKey) {
var trackActualOwner = 1e4 > ReactSharedInternals.recentlyCreatedOwnerStacks++;
return jsxDEVImpl(
type,
config,
maybeKey,
true,
trackActualOwner ? Error("react-stack-top-frame") : unknownOwnerDebugStack,
trackActualOwner ? createTask(getTaskName(type)) : unknownOwnerDebugTask
);
};
})();
}
});
// node_modules/react/jsx-runtime.js
var require_jsx_runtime = __commonJS({
"node_modules/react/jsx-runtime.js"(exports, module) {
if (false) {
module.exports = null;
} else {
module.exports = require_react_jsx_runtime_development();
}
}
});
export default require_jsx_runtime();
//# sourceMappingURL=react_jsx-runtime.js.map

File diff suppressed because one or more lines are too long

2506
app/node_modules/.vite/deps/recharts.js generated vendored

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

877
app/node_modules/.vite/deps/three.js generated vendored
View File

@@ -1,877 +0,0 @@
import {
ACESFilmicToneMapping,
AddEquation,
AddOperation,
AdditiveAnimationBlendMode,
AdditiveBlending,
AgXToneMapping,
AlphaFormat,
AlwaysCompare,
AlwaysDepth,
AlwaysStencilFunc,
AmbientLight,
AnimationAction,
AnimationClip,
AnimationLoader,
AnimationMixer,
AnimationObjectGroup,
AnimationUtils,
ArcCurve,
ArrayCamera,
ArrowHelper,
AttachedBindMode,
Audio,
AudioAnalyser,
AudioContext,
AudioListener,
AudioLoader,
AxesHelper,
BackSide,
BasicDepthPacking,
BasicShadowMap,
BatchedMesh,
Bone,
BooleanKeyframeTrack,
Box2,
Box3,
Box3Helper,
BoxGeometry,
BoxHelper,
BufferAttribute,
BufferGeometry,
BufferGeometryLoader,
ByteType,
Cache,
Camera,
CameraHelper,
CanvasTexture,
CapsuleGeometry,
CatmullRomCurve3,
CineonToneMapping,
CircleGeometry,
ClampToEdgeWrapping,
Clock,
Color,
ColorKeyframeTrack,
ColorManagement,
CompressedArrayTexture,
CompressedCubeTexture,
CompressedTexture,
CompressedTextureLoader,
ConeGeometry,
ConstantAlphaFactor,
ConstantColorFactor,
Controls,
CubeCamera,
CubeDepthTexture,
CubeReflectionMapping,
CubeRefractionMapping,
CubeTexture,
CubeTextureLoader,
CubeUVReflectionMapping,
CubicBezierCurve,
CubicBezierCurve3,
CubicInterpolant,
CullFaceBack,
CullFaceFront,
CullFaceFrontBack,
CullFaceNone,
Curve,
CurvePath,
CustomBlending,
CustomToneMapping,
CylinderGeometry,
Cylindrical,
Data3DTexture,
DataArrayTexture,
DataTexture,
DataTextureLoader,
DataUtils,
DecrementStencilOp,
DecrementWrapStencilOp,
DefaultLoadingManager,
DepthFormat,
DepthStencilFormat,
DepthTexture,
DetachedBindMode,
DirectionalLight,
DirectionalLightHelper,
DiscreteInterpolant,
DodecahedronGeometry,
DoubleSide,
DstAlphaFactor,
DstColorFactor,
DynamicCopyUsage,
DynamicDrawUsage,
DynamicReadUsage,
EdgesGeometry,
EllipseCurve,
EqualCompare,
EqualDepth,
EqualStencilFunc,
EquirectangularReflectionMapping,
EquirectangularRefractionMapping,
Euler,
EventDispatcher,
ExternalTexture,
ExtrudeGeometry,
FileLoader,
Float16BufferAttribute,
Float32BufferAttribute,
FloatType,
Fog,
FogExp2,
FramebufferTexture,
FrontSide,
Frustum,
FrustumArray,
GLBufferAttribute,
GLSL1,
GLSL3,
GreaterCompare,
GreaterDepth,
GreaterEqualCompare,
GreaterEqualDepth,
GreaterEqualStencilFunc,
GreaterStencilFunc,
GridHelper,
Group,
HalfFloatType,
HemisphereLight,
HemisphereLightHelper,
IcosahedronGeometry,
ImageBitmapLoader,
ImageLoader,
ImageUtils,
IncrementStencilOp,
IncrementWrapStencilOp,
InstancedBufferAttribute,
InstancedBufferGeometry,
InstancedInterleavedBuffer,
InstancedMesh,
Int16BufferAttribute,
Int32BufferAttribute,
Int8BufferAttribute,
IntType,
InterleavedBuffer,
InterleavedBufferAttribute,
Interpolant,
InterpolateDiscrete,
InterpolateLinear,
InterpolateSmooth,
InterpolationSamplingMode,
InterpolationSamplingType,
InvertStencilOp,
KeepStencilOp,
KeyframeTrack,
LOD,
LatheGeometry,
Layers,
LessCompare,
LessDepth,
LessEqualCompare,
LessEqualDepth,
LessEqualStencilFunc,
LessStencilFunc,
Light,
LightProbe,
Line,
Line3,
LineBasicMaterial,
LineCurve,
LineCurve3,
LineDashedMaterial,
LineLoop,
LineSegments,
LinearFilter,
LinearInterpolant,
LinearMipMapLinearFilter,
LinearMipMapNearestFilter,
LinearMipmapLinearFilter,
LinearMipmapNearestFilter,
LinearSRGBColorSpace,
LinearToneMapping,
LinearTransfer,
Loader,
LoaderUtils,
LoadingManager,
LoopOnce,
LoopPingPong,
LoopRepeat,
MOUSE,
Material,
MaterialLoader,
MathUtils,
Matrix2,
Matrix3,
Matrix4,
MaxEquation,
Mesh,
MeshBasicMaterial,
MeshDepthMaterial,
MeshDistanceMaterial,
MeshLambertMaterial,
MeshMatcapMaterial,
MeshNormalMaterial,
MeshPhongMaterial,
MeshPhysicalMaterial,
MeshStandardMaterial,
MeshToonMaterial,
MinEquation,
MirroredRepeatWrapping,
MixOperation,
MultiplyBlending,
MultiplyOperation,
NearestFilter,
NearestMipMapLinearFilter,
NearestMipMapNearestFilter,
NearestMipmapLinearFilter,
NearestMipmapNearestFilter,
NeutralToneMapping,
NeverCompare,
NeverDepth,
NeverStencilFunc,
NoBlending,
NoColorSpace,
NoNormalPacking,
NoToneMapping,
NormalAnimationBlendMode,
NormalBlending,
NormalGAPacking,
NormalRGPacking,
NotEqualCompare,
NotEqualDepth,
NotEqualStencilFunc,
NumberKeyframeTrack,
Object3D,
ObjectLoader,
ObjectSpaceNormalMap,
OctahedronGeometry,
OneFactor,
OneMinusConstantAlphaFactor,
OneMinusConstantColorFactor,
OneMinusDstAlphaFactor,
OneMinusDstColorFactor,
OneMinusSrcAlphaFactor,
OneMinusSrcColorFactor,
OrthographicCamera,
PCFShadowMap,
PCFSoftShadowMap,
PMREMGenerator,
Path,
PerspectiveCamera,
Plane,
PlaneGeometry,
PlaneHelper,
PointLight,
PointLightHelper,
Points,
PointsMaterial,
PolarGridHelper,
PolyhedronGeometry,
PositionalAudio,
PropertyBinding,
PropertyMixer,
QuadraticBezierCurve,
QuadraticBezierCurve3,
Quaternion,
QuaternionKeyframeTrack,
QuaternionLinearInterpolant,
R11_EAC_Format,
RED_GREEN_RGTC2_Format,
RED_RGTC1_Format,
REVISION,
RG11_EAC_Format,
RGBADepthPacking,
RGBAFormat,
RGBAIntegerFormat,
RGBA_ASTC_10x10_Format,
RGBA_ASTC_10x5_Format,
RGBA_ASTC_10x6_Format,
RGBA_ASTC_10x8_Format,
RGBA_ASTC_12x10_Format,
RGBA_ASTC_12x12_Format,
RGBA_ASTC_4x4_Format,
RGBA_ASTC_5x4_Format,
RGBA_ASTC_5x5_Format,
RGBA_ASTC_6x5_Format,
RGBA_ASTC_6x6_Format,
RGBA_ASTC_8x5_Format,
RGBA_ASTC_8x6_Format,
RGBA_ASTC_8x8_Format,
RGBA_BPTC_Format,
RGBA_ETC2_EAC_Format,
RGBA_PVRTC_2BPPV1_Format,
RGBA_PVRTC_4BPPV1_Format,
RGBA_S3TC_DXT1_Format,
RGBA_S3TC_DXT3_Format,
RGBA_S3TC_DXT5_Format,
RGBDepthPacking,
RGBFormat,
RGBIntegerFormat,
RGB_BPTC_SIGNED_Format,
RGB_BPTC_UNSIGNED_Format,
RGB_ETC1_Format,
RGB_ETC2_Format,
RGB_PVRTC_2BPPV1_Format,
RGB_PVRTC_4BPPV1_Format,
RGB_S3TC_DXT1_Format,
RGDepthPacking,
RGFormat,
RGIntegerFormat,
RawShaderMaterial,
Ray,
Raycaster,
RectAreaLight,
RedFormat,
RedIntegerFormat,
ReinhardToneMapping,
RenderTarget,
RenderTarget3D,
RepeatWrapping,
ReplaceStencilOp,
ReverseSubtractEquation,
RingGeometry,
SIGNED_R11_EAC_Format,
SIGNED_RED_GREEN_RGTC2_Format,
SIGNED_RED_RGTC1_Format,
SIGNED_RG11_EAC_Format,
SRGBColorSpace,
SRGBTransfer,
Scene,
ShaderChunk,
ShaderLib,
ShaderMaterial,
ShadowMaterial,
Shape,
ShapeGeometry,
ShapePath,
ShapeUtils,
ShortType,
Skeleton,
SkeletonHelper,
SkinnedMesh,
Source,
Sphere,
SphereGeometry,
Spherical,
SphericalHarmonics3,
SplineCurve,
SpotLight,
SpotLightHelper,
Sprite,
SpriteMaterial,
SrcAlphaFactor,
SrcAlphaSaturateFactor,
SrcColorFactor,
StaticCopyUsage,
StaticDrawUsage,
StaticReadUsage,
StereoCamera,
StreamCopyUsage,
StreamDrawUsage,
StreamReadUsage,
StringKeyframeTrack,
SubtractEquation,
SubtractiveBlending,
TOUCH,
TangentSpaceNormalMap,
TetrahedronGeometry,
Texture,
TextureLoader,
TextureUtils,
Timer,
TimestampQuery,
TorusGeometry,
TorusKnotGeometry,
Triangle,
TriangleFanDrawMode,
TriangleStripDrawMode,
TrianglesDrawMode,
TubeGeometry,
UVMapping,
Uint16BufferAttribute,
Uint32BufferAttribute,
Uint8BufferAttribute,
Uint8ClampedBufferAttribute,
Uniform,
UniformsGroup,
UniformsLib,
UniformsUtils,
UnsignedByteType,
UnsignedInt101111Type,
UnsignedInt248Type,
UnsignedInt5999Type,
UnsignedIntType,
UnsignedShort4444Type,
UnsignedShort5551Type,
UnsignedShortType,
VSMShadowMap,
Vector2,
Vector3,
Vector4,
VectorKeyframeTrack,
VideoFrameTexture,
VideoTexture,
WebGL3DRenderTarget,
WebGLArrayRenderTarget,
WebGLCoordinateSystem,
WebGLCubeRenderTarget,
WebGLRenderTarget,
WebGLRenderer,
WebGLUtils,
WebGPUCoordinateSystem,
WebXRController,
WireframeGeometry,
WrapAroundEnding,
ZeroCurvatureEnding,
ZeroFactor,
ZeroSlopeEnding,
ZeroStencilOp,
createCanvasElement,
error,
getConsoleFunction,
log,
setConsoleFunction,
warn,
warnOnce
} from "./chunk-INS7YHTD.js";
import "./chunk-G3PMV62Z.js";
export {
ACESFilmicToneMapping,
AddEquation,
AddOperation,
AdditiveAnimationBlendMode,
AdditiveBlending,
AgXToneMapping,
AlphaFormat,
AlwaysCompare,
AlwaysDepth,
AlwaysStencilFunc,
AmbientLight,
AnimationAction,
AnimationClip,
AnimationLoader,
AnimationMixer,
AnimationObjectGroup,
AnimationUtils,
ArcCurve,
ArrayCamera,
ArrowHelper,
AttachedBindMode,
Audio,
AudioAnalyser,
AudioContext,
AudioListener,
AudioLoader,
AxesHelper,
BackSide,
BasicDepthPacking,
BasicShadowMap,
BatchedMesh,
Bone,
BooleanKeyframeTrack,
Box2,
Box3,
Box3Helper,
BoxGeometry,
BoxHelper,
BufferAttribute,
BufferGeometry,
BufferGeometryLoader,
ByteType,
Cache,
Camera,
CameraHelper,
CanvasTexture,
CapsuleGeometry,
CatmullRomCurve3,
CineonToneMapping,
CircleGeometry,
ClampToEdgeWrapping,
Clock,
Color,
ColorKeyframeTrack,
ColorManagement,
CompressedArrayTexture,
CompressedCubeTexture,
CompressedTexture,
CompressedTextureLoader,
ConeGeometry,
ConstantAlphaFactor,
ConstantColorFactor,
Controls,
CubeCamera,
CubeDepthTexture,
CubeReflectionMapping,
CubeRefractionMapping,
CubeTexture,
CubeTextureLoader,
CubeUVReflectionMapping,
CubicBezierCurve,
CubicBezierCurve3,
CubicInterpolant,
CullFaceBack,
CullFaceFront,
CullFaceFrontBack,
CullFaceNone,
Curve,
CurvePath,
CustomBlending,
CustomToneMapping,
CylinderGeometry,
Cylindrical,
Data3DTexture,
DataArrayTexture,
DataTexture,
DataTextureLoader,
DataUtils,
DecrementStencilOp,
DecrementWrapStencilOp,
DefaultLoadingManager,
DepthFormat,
DepthStencilFormat,
DepthTexture,
DetachedBindMode,
DirectionalLight,
DirectionalLightHelper,
DiscreteInterpolant,
DodecahedronGeometry,
DoubleSide,
DstAlphaFactor,
DstColorFactor,
DynamicCopyUsage,
DynamicDrawUsage,
DynamicReadUsage,
EdgesGeometry,
EllipseCurve,
EqualCompare,
EqualDepth,
EqualStencilFunc,
EquirectangularReflectionMapping,
EquirectangularRefractionMapping,
Euler,
EventDispatcher,
ExternalTexture,
ExtrudeGeometry,
FileLoader,
Float16BufferAttribute,
Float32BufferAttribute,
FloatType,
Fog,
FogExp2,
FramebufferTexture,
FrontSide,
Frustum,
FrustumArray,
GLBufferAttribute,
GLSL1,
GLSL3,
GreaterCompare,
GreaterDepth,
GreaterEqualCompare,
GreaterEqualDepth,
GreaterEqualStencilFunc,
GreaterStencilFunc,
GridHelper,
Group,
HalfFloatType,
HemisphereLight,
HemisphereLightHelper,
IcosahedronGeometry,
ImageBitmapLoader,
ImageLoader,
ImageUtils,
IncrementStencilOp,
IncrementWrapStencilOp,
InstancedBufferAttribute,
InstancedBufferGeometry,
InstancedInterleavedBuffer,
InstancedMesh,
Int16BufferAttribute,
Int32BufferAttribute,
Int8BufferAttribute,
IntType,
InterleavedBuffer,
InterleavedBufferAttribute,
Interpolant,
InterpolateDiscrete,
InterpolateLinear,
InterpolateSmooth,
InterpolationSamplingMode,
InterpolationSamplingType,
InvertStencilOp,
KeepStencilOp,
KeyframeTrack,
LOD,
LatheGeometry,
Layers,
LessCompare,
LessDepth,
LessEqualCompare,
LessEqualDepth,
LessEqualStencilFunc,
LessStencilFunc,
Light,
LightProbe,
Line,
Line3,
LineBasicMaterial,
LineCurve,
LineCurve3,
LineDashedMaterial,
LineLoop,
LineSegments,
LinearFilter,
LinearInterpolant,
LinearMipMapLinearFilter,
LinearMipMapNearestFilter,
LinearMipmapLinearFilter,
LinearMipmapNearestFilter,
LinearSRGBColorSpace,
LinearToneMapping,
LinearTransfer,
Loader,
LoaderUtils,
LoadingManager,
LoopOnce,
LoopPingPong,
LoopRepeat,
MOUSE,
Material,
MaterialLoader,
MathUtils,
Matrix2,
Matrix3,
Matrix4,
MaxEquation,
Mesh,
MeshBasicMaterial,
MeshDepthMaterial,
MeshDistanceMaterial,
MeshLambertMaterial,
MeshMatcapMaterial,
MeshNormalMaterial,
MeshPhongMaterial,
MeshPhysicalMaterial,
MeshStandardMaterial,
MeshToonMaterial,
MinEquation,
MirroredRepeatWrapping,
MixOperation,
MultiplyBlending,
MultiplyOperation,
NearestFilter,
NearestMipMapLinearFilter,
NearestMipMapNearestFilter,
NearestMipmapLinearFilter,
NearestMipmapNearestFilter,
NeutralToneMapping,
NeverCompare,
NeverDepth,
NeverStencilFunc,
NoBlending,
NoColorSpace,
NoNormalPacking,
NoToneMapping,
NormalAnimationBlendMode,
NormalBlending,
NormalGAPacking,
NormalRGPacking,
NotEqualCompare,
NotEqualDepth,
NotEqualStencilFunc,
NumberKeyframeTrack,
Object3D,
ObjectLoader,
ObjectSpaceNormalMap,
OctahedronGeometry,
OneFactor,
OneMinusConstantAlphaFactor,
OneMinusConstantColorFactor,
OneMinusDstAlphaFactor,
OneMinusDstColorFactor,
OneMinusSrcAlphaFactor,
OneMinusSrcColorFactor,
OrthographicCamera,
PCFShadowMap,
PCFSoftShadowMap,
PMREMGenerator,
Path,
PerspectiveCamera,
Plane,
PlaneGeometry,
PlaneHelper,
PointLight,
PointLightHelper,
Points,
PointsMaterial,
PolarGridHelper,
PolyhedronGeometry,
PositionalAudio,
PropertyBinding,
PropertyMixer,
QuadraticBezierCurve,
QuadraticBezierCurve3,
Quaternion,
QuaternionKeyframeTrack,
QuaternionLinearInterpolant,
R11_EAC_Format,
RED_GREEN_RGTC2_Format,
RED_RGTC1_Format,
REVISION,
RG11_EAC_Format,
RGBADepthPacking,
RGBAFormat,
RGBAIntegerFormat,
RGBA_ASTC_10x10_Format,
RGBA_ASTC_10x5_Format,
RGBA_ASTC_10x6_Format,
RGBA_ASTC_10x8_Format,
RGBA_ASTC_12x10_Format,
RGBA_ASTC_12x12_Format,
RGBA_ASTC_4x4_Format,
RGBA_ASTC_5x4_Format,
RGBA_ASTC_5x5_Format,
RGBA_ASTC_6x5_Format,
RGBA_ASTC_6x6_Format,
RGBA_ASTC_8x5_Format,
RGBA_ASTC_8x6_Format,
RGBA_ASTC_8x8_Format,
RGBA_BPTC_Format,
RGBA_ETC2_EAC_Format,
RGBA_PVRTC_2BPPV1_Format,
RGBA_PVRTC_4BPPV1_Format,
RGBA_S3TC_DXT1_Format,
RGBA_S3TC_DXT3_Format,
RGBA_S3TC_DXT5_Format,
RGBDepthPacking,
RGBFormat,
RGBIntegerFormat,
RGB_BPTC_SIGNED_Format,
RGB_BPTC_UNSIGNED_Format,
RGB_ETC1_Format,
RGB_ETC2_Format,
RGB_PVRTC_2BPPV1_Format,
RGB_PVRTC_4BPPV1_Format,
RGB_S3TC_DXT1_Format,
RGDepthPacking,
RGFormat,
RGIntegerFormat,
RawShaderMaterial,
Ray,
Raycaster,
RectAreaLight,
RedFormat,
RedIntegerFormat,
ReinhardToneMapping,
RenderTarget,
RenderTarget3D,
RepeatWrapping,
ReplaceStencilOp,
ReverseSubtractEquation,
RingGeometry,
SIGNED_R11_EAC_Format,
SIGNED_RED_GREEN_RGTC2_Format,
SIGNED_RED_RGTC1_Format,
SIGNED_RG11_EAC_Format,
SRGBColorSpace,
SRGBTransfer,
Scene,
ShaderChunk,
ShaderLib,
ShaderMaterial,
ShadowMaterial,
Shape,
ShapeGeometry,
ShapePath,
ShapeUtils,
ShortType,
Skeleton,
SkeletonHelper,
SkinnedMesh,
Source,
Sphere,
SphereGeometry,
Spherical,
SphericalHarmonics3,
SplineCurve,
SpotLight,
SpotLightHelper,
Sprite,
SpriteMaterial,
SrcAlphaFactor,
SrcAlphaSaturateFactor,
SrcColorFactor,
StaticCopyUsage,
StaticDrawUsage,
StaticReadUsage,
StereoCamera,
StreamCopyUsage,
StreamDrawUsage,
StreamReadUsage,
StringKeyframeTrack,
SubtractEquation,
SubtractiveBlending,
TOUCH,
TangentSpaceNormalMap,
TetrahedronGeometry,
Texture,
TextureLoader,
TextureUtils,
Timer,
TimestampQuery,
TorusGeometry,
TorusKnotGeometry,
Triangle,
TriangleFanDrawMode,
TriangleStripDrawMode,
TrianglesDrawMode,
TubeGeometry,
UVMapping,
Uint16BufferAttribute,
Uint32BufferAttribute,
Uint8BufferAttribute,
Uint8ClampedBufferAttribute,
Uniform,
UniformsGroup,
UniformsLib,
UniformsUtils,
UnsignedByteType,
UnsignedInt101111Type,
UnsignedInt248Type,
UnsignedInt5999Type,
UnsignedIntType,
UnsignedShort4444Type,
UnsignedShort5551Type,
UnsignedShortType,
VSMShadowMap,
Vector2,
Vector3,
Vector4,
VectorKeyframeTrack,
VideoFrameTexture,
VideoTexture,
WebGL3DRenderTarget,
WebGLArrayRenderTarget,
WebGLCoordinateSystem,
WebGLCubeRenderTarget,
WebGLRenderTarget,
WebGLRenderer,
WebGLUtils,
WebGPUCoordinateSystem,
WebXRController,
WireframeGeometry,
WrapAroundEnding,
ZeroCurvatureEnding,
ZeroFactor,
ZeroSlopeEnding,
ZeroStencilOp,
createCanvasElement,
error,
getConsoleFunction,
log,
setConsoleFunction,
warn,
warnOnce
};

View File

@@ -1,7 +0,0 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}

View File

@@ -1,14 +0,0 @@
import {
create,
useStore
} from "./chunk-QJTQF54Q.js";
import {
createStore
} from "./chunk-LTNRPUSL.js";
import "./chunk-ZNKPWGXJ.js";
import "./chunk-G3PMV62Z.js";
export {
create,
createStore,
useStore
};

View File

@@ -1,7 +0,0 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}

View File

@@ -1,19 +0,0 @@
import {
combine,
createJSONStorage,
devtools,
persist,
redux,
ssrSafe,
subscribeWithSelector
} from "./chunk-XGWIEMTH.js";
import "./chunk-G3PMV62Z.js";
export {
combine,
createJSONStorage,
devtools,
persist,
redux,
subscribeWithSelector,
ssrSafe as unstable_ssrSafe
};

View File

@@ -1,7 +0,0 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import type {
Client360Snapshot,
CrmOpportunityCard,
CrmTask,
CrmLeadStageUpdate,
KanbanColumn,
ImportBatchSummary,
ImportProposal,
@@ -17,13 +18,12 @@ import type {
OracleClientDataDetail,
OracleClientTimelineItem,
} 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> {
@@ -90,6 +90,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?: {
@@ -121,6 +138,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[]> {
@@ -128,6 +162,21 @@ export async function fetchKanbanBoard(): Promise<KanbanColumn[]> {
return res.data;
}
export async function updateLeadStage(body: {
lead_id: string;
status: string;
notes?: string;
}): Promise<CrmLeadStageUpdate> {
const res = await apiFetch<{ status: string; data: CrmLeadStageUpdate }>(`/api/crm/leads/${body.lead_id}/stage`, {
method: 'PATCH',
body: JSON.stringify({
status: body.status,
notes: body.notes,
}),
});
return res.data;
}
// ── QD Scores ─────────────────────────────────────────────────────────────────
export async function fetchQdScore(personId: string): Promise<{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,13 +11,12 @@ import os
import json
import asyncio
import logging
import re
from contextlib import asynccontextmanager
from datetime import UTC, datetime
from datetime import datetime, timezone
from pathlib import Path
from typing import Set
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, HTTPException, status, UploadFile, File
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from dotenv import load_dotenv
@@ -62,12 +61,14 @@ 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.api.routes_runtime_llm import router as runtime_llm_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
@@ -86,6 +87,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
@@ -118,6 +124,7 @@ app.add_middleware(
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(RequestObservabilityMiddleware)
# ── Static asset serving (Vault files) ───────────────────────────────────────
@@ -125,11 +132,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"])
@@ -146,6 +148,7 @@ app.include_router(vault_router, prefix="/api/vault", tags=["Vault"])
app.include_router(mobile_edge_router, prefix="/api/mobile-edge", tags=["Mobile Edge"])
app.include_router(inventory_router, prefix="/api/inventory", tags=["Inventory"])
app.include_router(admin_surface_router, prefix="/api/admin-surface", tags=["Admin Surface"])
app.include_router(observability_router, prefix="/api", tags=["Observability"])
app.include_router(crm_imports_router, prefix="/api", tags=["CRM Canonical"])
app.include_router(runtime_llm_router, prefix="/api/runtime/llm", tags=["Runtime LLM"])
@@ -153,144 +156,6 @@ app.include_router(runtime_llm_router, prefix="/api/runtime/llm", tags=["Runtime
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:
@@ -359,7 +224,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:
@@ -376,7 +241,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)
@@ -387,7 +252,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)
@@ -406,6 +271,5 @@ async def health() -> dict:
"service": "velocity-backend",
"version": "2.0.0",
"db_pool": "connected" if db_ok else "unavailable",
"timestamp": datetime.now(UTC).isoformat(),
"timestamp": datetime.now(timezone.utc).isoformat(),
}

View File

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

View File

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

View File

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

View File

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

View File

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

103
backend/observability.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,181 +0,0 @@
import SwiftUI
enum AppSection: String, CaseIterable, Hashable, Identifiable {
var id: String { rawValue }
case dashboard = "Dashboard"
case communications = "Communications"
case calendar = "Calendar"
case oracle = "Oracle"
case sentinel = "Sentinel"
case inventory = "Inventory"
case settings = "Settings"
var systemImage: String {
switch self {
case .dashboard: return "square.grid.2x2"
case .communications: return "phone.connection"
case .calendar: return "calendar.badge.clock"
case .oracle: return "message.and.waveform"
case .sentinel: return "person.crop.rectangle"
case .inventory: return "shippingbox"
case .settings: return "gearshape"
}
}
var accentColor: Color {
switch self {
case .dashboard: return VelocityTheme.accent
case .communications: return Color(red: 0.19, green: 0.84, blue: 0.63)
case .calendar: return Color(red: 0.96, green: 0.67, blue: 0.16)
case .oracle: return Color(red: 0.13, green: 0.83, blue: 0.93) // cyan
case .sentinel: return Color(red: 0.60, green: 0.57, blue: 0.99) // indigo
case .inventory: return VelocityTheme.warning
case .settings: return VelocityTheme.mutedFg
}
}
}
struct ContentView: View {
@State private var selectedSection: AppSection? = .dashboard
var body: some View {
NavigationSplitView(columnVisibility: .constant(.all)) {
sidebarContent
} detail: {
detailContent
}
.navigationSplitViewStyle(.balanced)
}
// MARK: Sidebar
private var sidebarContent: some View {
ZStack {
VelocityTheme.sidebarBg.ignoresSafeArea()
VStack(spacing: 0) {
// App title
HStack(spacing: 10) {
ZStack {
RoundedRectangle(cornerRadius: 9)
.fill(VelocityTheme.accent.opacity(0.18))
.frame(width: 34, height: 34)
Image(systemName: "bolt.fill")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(VelocityTheme.accent)
}
VStack(alignment: .leading, spacing: 1) {
Text("Velocity")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text("Project Velocity · v1.1")
.font(.system(size: 10))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.top, 20)
.padding(.bottom, 16)
Divider()
.background(VelocityTheme.borderSubtle)
.padding(.bottom, 8)
// Nav items
VStack(spacing: 2) {
ForEach(AppSection.allCases) { section in
SidebarRow(section: section,
isSelected: selectedSection == section)
.onTapGesture { selectedSection = section }
}
}
.padding(.horizontal, 8)
Spacer()
// User footer
Divider()
.background(VelocityTheme.borderSubtle)
HStack(spacing: 10) {
ZStack {
RoundedRectangle(cornerRadius: 8)
.fill(VelocityTheme.accent)
.frame(width: 32, height: 32)
Text("AF")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(.white)
}
VStack(alignment: .leading, spacing: 2) {
Text("Ahmed Al-Farsi")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(VelocityTheme.foreground)
Text("Sales Director")
.font(.system(size: 10))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
}
.padding(16)
}
}
.navigationTitle("")
.toolbar(.hidden, for: .navigationBar)
}
// MARK: Detail
private var detailContent: some View {
ZStack {
VelocityTheme.background.ignoresSafeArea()
Group {
switch selectedSection {
case .dashboard: DashboardView()
case .communications: CommunicationsView()
case .calendar: CalendarView()
case .oracle: OracleView()
case .sentinel: SentinelView()
case .inventory: InventoryView()
case .settings: SettingsView()
case .none: DashboardView()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
// MARK: Sidebar Row
private struct SidebarRow: View {
let section: AppSection
let isSelected: Bool
var body: some View {
HStack(spacing: 11) {
Image(systemName: section.systemImage)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(isSelected ? section.accentColor : VelocityTheme.mutedFg)
.frame(width: 20)
Text(section.rawValue)
.font(.system(size: 14, weight: isSelected ? .semibold : .regular))
.foregroundStyle(isSelected ? VelocityTheme.foreground : VelocityTheme.mutedFg)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 9)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(isSelected ? section.accentColor.opacity(0.12) : .clear)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(isSelected ? section.accentColor.opacity(0.25) : .clear, lineWidth: 1)
)
)
.contentShape(Rectangle())
}
}
#Preview {
ContentView()
}

View File

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

View File

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

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