forked from sagnik/Project_Velocity
Compare commits
2 Commits
9e981e79ea
...
34e226a36e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34e226a36e | ||
|
|
fefe8373ec |
565
.Agent Context/Codebase Analysis v1.1.md
Normal file
565
.Agent Context/Codebase Analysis v1.1.md
Normal file
@@ -0,0 +1,565 @@
|
||||
# Codebase Analysis v1.1.md
|
||||
|
||||
## Table of Contents / Chapters
|
||||
|
||||
### 1. Overview
|
||||
- Introduction to Project Velocity and its purpose
|
||||
- Core principles and approach
|
||||
|
||||
### 2. Architectural Mapping
|
||||
- Overall System Architecture (Mermaid diagram)
|
||||
- File Dependency Graph (Mermaid diagram)
|
||||
- Data Flow Architecture (Mermaid diagram)
|
||||
|
||||
### 3. Logic Decomposition
|
||||
- Authentication & Authorization
|
||||
- CRM Data Model
|
||||
- Sentinel Biometric Intelligence
|
||||
- Oracle Natural Language Intelligence
|
||||
- Catalyst Marketing Orchestration
|
||||
- Infrastructure & Deployment
|
||||
|
||||
### 4. Connectivity Matrix
|
||||
- Component interconnections and data flow
|
||||
- Interconnection rationale
|
||||
|
||||
### 5. First-Principles Guide
|
||||
- Core Concept: AI-Augmented Sales Intelligence
|
||||
- Why Real Estate Specifically?
|
||||
- Principle 1: Data Sovereignty First
|
||||
- Principle 2: Real-Time Perception Matters
|
||||
- Principle 3: Intelligence Through Conversation
|
||||
- Principle 4: Visual Storytelling Drives Sales
|
||||
- Principle 5: Revision Control for Business Logic
|
||||
- Design Philosophy: Production-Ready Craft
|
||||
- Why This Architecture Succeeds
|
||||
|
||||
### 6. API Endpoints Reference
|
||||
- Authentication Endpoints
|
||||
- CRM Endpoints
|
||||
- Analytics Endpoints
|
||||
- Oracle AI Intelligence Endpoints
|
||||
- Oracle Canvas Management (v1)
|
||||
- Oracle Template Management
|
||||
- Sentinel Biometric Intelligence Endpoints
|
||||
- Catalyst Marketing Orchestration Endpoints
|
||||
- Vault Trackable Links Endpoints
|
||||
- CCTV Surveillance Integration Endpoints
|
||||
- Video Scene Mapping Endpoints
|
||||
- Marketing Videos Endpoints
|
||||
- Mobile Edge Communication Endpoints
|
||||
- Inventory Management Endpoints
|
||||
- Admin Surface Control Endpoints
|
||||
- CRM Canonical Data Endpoints
|
||||
- Runtime LLM Endpoints
|
||||
- Infrastructure Notes
|
||||
|
||||
## Overview
|
||||
|
||||
Project Velocity is an on-prem real estate operating system designed for high-value property sales. It combines a premium WebOS, an iPad field app, a FastAPI neural core, ComfyUI-based media generation, and biometric/sentiment-assisted sales intelligence. The system enables brokers to operate at the speed of AI while preserving control, provenance, and safety for customer and revenue-critical data.
|
||||
|
||||
This analysis provides a comprehensive understanding of the codebase from first principles, applying the Feynman Technique to distill complex implementations into intuitive concepts.
|
||||
|
||||
## Architectural Mapping
|
||||
|
||||
### Overall System Architecture
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "User Interfaces"
|
||||
WebOS[Velocity WebOS<br/>React + TypeScript]
|
||||
iPad[iPad App<br/>Swift + MediaPipe]
|
||||
end
|
||||
|
||||
subgraph "Core Backend"
|
||||
FastAPI[FastAPI Neural Core<br/>PostgreSQL + JWT Auth]
|
||||
end
|
||||
|
||||
subgraph "AI Services"
|
||||
Oracle[The Oracle<br/>Natural Language Intelligence]
|
||||
Sentinel[The Sentinel<br/>Biometric Perception Engine]
|
||||
Catalyst[The Catalyst<br/>Marketing Campaign Orchestration]
|
||||
Comfy[ComfyUI / Dream Weaver<br/>Media Generation]
|
||||
end
|
||||
|
||||
subgraph "Infrastructure"
|
||||
AWS[AWS GPU Workers<br/>NVIDIA GPUs]
|
||||
S3[S3 Asset Store<br/>Models + Media]
|
||||
Linux[Linux Control Surface<br/>On-prem Deployment]
|
||||
end
|
||||
|
||||
WebOS --> FastAPI
|
||||
iPad --> FastAPI
|
||||
FastAPI --> Oracle
|
||||
FastAPI --> Sentinel
|
||||
FastAPI --> Catalyst
|
||||
Catalyst --> Comfy
|
||||
Comfy --> AWS
|
||||
Comfy --> S3
|
||||
FastAPI --> Linux
|
||||
|
||||
style FastAPI fill:#e1f5fe
|
||||
style Oracle fill:#f3e5f5
|
||||
style Sentinel fill:#e8f5e8
|
||||
style Catalyst fill:#fff3e0
|
||||
```
|
||||
|
||||
### File Dependency Graph
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "Frontend (React/Vite)"
|
||||
App[App.tsx<br/>Routing & Auth]
|
||||
Store[useStore.ts<br/>Zustand State]
|
||||
Components[Components/<br/>Modules & UI]
|
||||
API[api.ts<br/>HTTP Client]
|
||||
end
|
||||
|
||||
subgraph "Backend (FastAPI)"
|
||||
Main[main.py<br/>App Entry]
|
||||
Routers[routers/<br/>API Endpoints]
|
||||
Services[services/<br/>Business Logic]
|
||||
DB[db/<br/>Schema & Pool]
|
||||
Auth[auth/<br/>JWT & Users]
|
||||
Oracle[oracle/<br/>AI Intelligence]
|
||||
end
|
||||
|
||||
subgraph "AI Infrastructure"
|
||||
Comfy[comfy_engine/<br/>Media Generation]
|
||||
Models[models/<br/>AI Models]
|
||||
Prompts[nemoclaw_prompts/<br/>LLM Templates]
|
||||
end
|
||||
|
||||
subgraph "Deployment"
|
||||
Infra[infrastructure/<br/>Linux + AWS]
|
||||
Agents[agents/<br/>Orchestration]
|
||||
end
|
||||
|
||||
App --> API
|
||||
API --> Main
|
||||
Main --> Routers
|
||||
Routers --> Services
|
||||
Services --> DB
|
||||
Services --> Auth
|
||||
Services --> Oracle
|
||||
Oracle --> Prompts
|
||||
Comfy --> Infra
|
||||
Infra --> Agents
|
||||
|
||||
style Main fill:#e3f2fd
|
||||
style Oracle fill:#fce4ec
|
||||
```
|
||||
|
||||
### Data Flow Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
User[User Input] --> UI[WebOS/iPad UI]
|
||||
UI --> API[FastAPI Endpoints]
|
||||
API --> Auth[JWT Authentication]
|
||||
API --> Policy[Policy Engine<br/>Authorization]
|
||||
API --> LLM[Nemoclaw LLM<br/>Reasoning & Planning]
|
||||
LLM --> Query[SQL Generation<br/>Safe Queries]
|
||||
Query --> DB[(PostgreSQL<br/>CRM + Intelligence)]
|
||||
DB --> Results[Query Results]
|
||||
Results --> Viz[Visualization<br/>Components]
|
||||
Viz --> Canvas[Oracle Canvas<br/>Persistent Views]
|
||||
Canvas --> UI
|
||||
|
||||
Sentinel[Sentinel Biometric] --> WS[WebSocket<br/>Real-time]
|
||||
WS --> Perception[Face Analysis<br/>MediaPipe]
|
||||
Perception --> QD[QD Scoring<br/>NemoClaw]
|
||||
QD --> DB
|
||||
|
||||
Catalyst[Catalyst Marketing] --> Comfy[ComfyUI<br/>Media Generation]
|
||||
Comfy --> S3[S3 Assets]
|
||||
Comfy --> GPU[AWS GPUs]
|
||||
|
||||
style DB fill:#fff9c4
|
||||
style LLM fill:#e8f5e8
|
||||
style Comfy fill:#fce4ec
|
||||
```
|
||||
|
||||
## Logic Decomposition
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
**What:** Secure access control for all system functions
|
||||
**How:** JWT tokens with role-based permissions (ADMIN, SALES_DIRECTOR, SENIOR_BROKER, JUNIOR_BROKER)
|
||||
**Why:** Real estate involves sensitive client data; strict access prevents unauthorized sales interference
|
||||
|
||||
**Key Implementation:**
|
||||
- `backend/auth/dependencies.py`: JWT validation, user extraction, role enforcement
|
||||
- `main.py`: Login endpoint with password hashing, user profile management
|
||||
- Role hierarchy prevents junior brokers from approving high-value deals
|
||||
|
||||
### CRM Data Model
|
||||
|
||||
**What:** Canonical client and interaction records
|
||||
**How:** PostgreSQL schema with leads, contacts, opportunities, interactions
|
||||
**Why:** Real estate sales require accurate pipeline tracking and relationship history
|
||||
|
||||
**Key Tables:**
|
||||
- `leads_intelligence`: Core lead data with QD scores and tags
|
||||
- `omnichannel_logs`: All interactions (calls, emails, visits) with timestamps
|
||||
- `crm_people/crm_leads`: Structured client relationships and deal stages
|
||||
|
||||
### Sentinel Biometric Intelligence
|
||||
|
||||
**What:** Real-time visitor sentiment analysis for showroom engagement
|
||||
**How:** Browser webcam + MediaPipe face landmarking → blend shapes → QD scoring
|
||||
**Why:** Human emotion drives buying decisions; AI detects subtle cues brokers miss
|
||||
|
||||
**Execution Flow:**
|
||||
1. Browser captures video stream
|
||||
2. MediaPipe extracts facial landmarks (68 points)
|
||||
3. Blend shapes calculated (brow furrow, smile intensity, etc.)
|
||||
4. NemoClaw LLM scores 1-100 QD (Qualification Desire)
|
||||
5. Real-time dashboard updates for brokers
|
||||
|
||||
**Key Decision:** Browser-side processing preserves privacy (no video leaves client device)
|
||||
|
||||
### Oracle Natural Language Intelligence
|
||||
|
||||
**What:** AI-powered data analysis through conversational queries
|
||||
**How:** Natural language → SQL planning → visualization components → canvas
|
||||
**Why:** Brokers shouldn't need SQL knowledge; AI translates business questions to insights
|
||||
|
||||
**Architecture Layers:**
|
||||
- **Prompt Orchestrator:** Decomposes complex requests into sub-queries
|
||||
- **Natural DB Agent:** Schema introspection + safe SQL generation
|
||||
- **Canvas Service:** Persistent visual workspaces with revision history
|
||||
- **Collaboration:** Fork/merge workflows for team coordination
|
||||
|
||||
**Why Canvas?** Unlike transient chat responses, canvases persist as living documents
|
||||
|
||||
### Catalyst Marketing Orchestration
|
||||
|
||||
**What:** Automated campaign creation and asset generation
|
||||
**How:** Meta Ads API integration + ComfyUI media workflows
|
||||
**Why:** Luxury real estate needs high-quality visuals; manual creation is too slow
|
||||
|
||||
**Workflow:**
|
||||
1. Campaign parameters → Meta API calls
|
||||
2. Creative assets → ComfyUI (Dream Weaver, Wan 2.2, Qwen poster generation)
|
||||
3. GPU processing on AWS → S3 storage
|
||||
4. Performance tracking back to CRM
|
||||
|
||||
### Infrastructure & Deployment
|
||||
|
||||
**What:** Production-ready on-prem + cloud hybrid deployment
|
||||
**How:** Linux control surface + AWS GPU workers + stable ingress
|
||||
**Why:** Real estate firms demand data sovereignty; cloud-only solutions unacceptable
|
||||
|
||||
**Key Components:**
|
||||
- **Ingress:** Caddy on EC2 t4g.micro with TLS termination
|
||||
- **Backend:** Linux systemd services for FastAPI, ComfyUI, LLM runtime
|
||||
- **GPU:** AWS managed instances for media generation
|
||||
- **Assets:** S3 for model storage, NVMe for fast access
|
||||
|
||||
## Connectivity Matrix
|
||||
|
||||
| Component | Inputs | Outputs | Dependencies | Protocols |
|
||||
|-----------|--------|---------|--------------|-----------|
|
||||
| WebOS Frontend | User actions, API responses | UI renders, API calls | FastAPI backend | HTTP/WS, JWT |
|
||||
| iPad App | Camera feeds, user input | Biometric data, inventory scans | FastAPI backend | HTTP/WS |
|
||||
| FastAPI Core | API requests, WS connections | DB queries, AI responses | PostgreSQL, Redis (future) | SQL, HTTP |
|
||||
| Oracle Engine | Natural language prompts | Canvas components | NemoClaw LLM, PostgreSQL | Internal API |
|
||||
| Sentinel Engine | Webcam streams | QD scores, alerts | MediaPipe, NemoClaw | WS real-time |
|
||||
| Catalyst Engine | Campaign specs | Ad creatives, assets | Meta API, ComfyUI | HTTP, S3 |
|
||||
| ComfyUI | Generation requests | Images/videos | GPU workers, S3 | Internal queue |
|
||||
| PostgreSQL | SQL queries | Structured data | - | SQL |
|
||||
| AWS GPUs | Media jobs | Generated assets | ComfyUI workflows | SSH/tunnel |
|
||||
|
||||
**Interconnection Rationale:**
|
||||
- **WebOS ↔ Backend:** Thin client architecture; all business logic server-side for security
|
||||
- **Backend ↔ AI Services:** Modular design; each AI component (Oracle, Sentinel, Catalyst) operates independently but shares auth/policy
|
||||
- **AI Services ↔ DB:** Direct SQL access with row-level security; no ORM abstraction to maintain performance
|
||||
- **Infrastructure:** Hybrid on-prem/cloud; sensitive data stays on-prem, compute-intensive tasks use cloud GPUs
|
||||
|
||||
## First-Principles Guide
|
||||
|
||||
### Core Concept: AI-Augmented Sales Intelligence
|
||||
|
||||
At its foundation, Project Velocity operates on the principle that human sales professionals excel at relationship-building and deal-closing, while AI excels at pattern recognition, data synthesis, and repetitive analysis. The system doesn't replace brokers—it amplifies their capabilities by providing real-time insights they couldn't otherwise access.
|
||||
|
||||
**Why Real Estate Specifically?**
|
||||
- High-value transactions ($M+ deals) with long sales cycles (months)
|
||||
- Emotional decision-making influenced by subtle cues
|
||||
- Complex data relationships (properties, buyers, markets, timing)
|
||||
- Regulatory compliance requirements
|
||||
- Need for visual storytelling (luxury properties)
|
||||
|
||||
### Principle 1: Data Sovereignty First
|
||||
|
||||
**Fundamental Truth:** Real estate firms own their client relationships. Project Velocity runs on-premise or in tenant-controlled cloud to maintain this ownership.
|
||||
|
||||
**Implementation:** Linux-based deployment with optional AWS GPU extensions. All client data remains within tenant boundaries; only anonymous model requests leave for AI processing.
|
||||
|
||||
### Principle 2: Real-Time Perception Matters
|
||||
|
||||
**Fundamental Truth:** Buying decisions happen in moments of emotional connection. Project Velocity captures these moments through biometric analysis.
|
||||
|
||||
**Implementation:** Sentinel uses facial expression analysis to score "Qualification Desire" (QD) on a 1-100 scale, alerting brokers to engagement spikes during property tours.
|
||||
|
||||
### Principle 3: Intelligence Through Conversation
|
||||
|
||||
**Fundamental Truth:** Sales professionals think in business terms, not database queries. The Oracle translates natural language into structured analytics.
|
||||
|
||||
**Implementation:** Users ask "Show me whale leads from Dubai this quarter" and receive visual dashboards. The system plans safe SQL queries, executes them, and renders results as persistent canvas components.
|
||||
|
||||
### Principle 4: Visual Storytelling Drives Sales
|
||||
|
||||
**Fundamental Truth:** Luxury properties sell through aspiration and emotion. AI-generated media must be photorealistic and brand-consistent.
|
||||
|
||||
**Implementation:** ComfyUI workflows (Dream Weaver, Wan 2.2) create property visualizations. Catalyst orchestrates campaigns with generated assets automatically uploaded to Meta Ads.
|
||||
|
||||
### Principle 5: Revision Control for Business Logic
|
||||
|
||||
**Fundamental Truth:** Sales strategies evolve through collaboration and iteration. Oracle canvases use Git-like branching for analytical workflows.
|
||||
|
||||
**Implementation:** Canvas pages support forks, merge requests, and revision history. Brokers can experiment with analysis approaches without breaking production views.
|
||||
|
||||
### Design Philosophy: Production-Ready Craft
|
||||
|
||||
Project Velocity follows "experienced engineer" principles:
|
||||
- **Error Handling:** No silent failures; all errors surface useful messages
|
||||
- **Type Safety:** TypeScript frontend, typed Python backend
|
||||
- **Performance:** Async everywhere, connection pooling, efficient queries
|
||||
- **Security:** JWT auth, role-based access, input validation
|
||||
- **Observability:** Structured logging, health checks, WebSocket monitoring
|
||||
- **Maintainability:** Clear separation of concerns, comprehensive documentation
|
||||
|
||||
### Why This Architecture Succeeds
|
||||
|
||||
1. **Modular AI Services:** Each intelligence component (Oracle, Sentinel, Catalyst) operates independently, allowing incremental improvement and specialized optimization.
|
||||
|
||||
2. **Hybrid Infrastructure:** Combines on-prem reliability with cloud scalability. Sensitive CRM data stays local; compute-intensive media generation uses managed GPUs.
|
||||
|
||||
3. **Real-Time Integration:** WebSockets enable live updates across all surfaces. Brokers see lead scoring changes instantly, dashboard metrics update in real-time.
|
||||
|
||||
4. **Business Logic in Code:** Revenue-critical workflows (lead qualification, campaign approval, deal closing) are explicit code paths, not AI hallucinations.
|
||||
|
||||
5. **User-Centric Design:** The WebOS feels like a native application, not a bolted-on AI interface. Familiar patterns (canvases, dashboards, forms) reduce training time.
|
||||
|
||||
This architecture transforms real estate sales from intuition-driven processes into data-augmented, AI-accelerated operations while preserving the human elements that drive luxury transactions.
|
||||
|
||||
## API Endpoints Reference
|
||||
|
||||
This section provides a comprehensive catalog of all API endpoints exposed by the Project Velocity backend, organized by functional module. Each endpoint includes the HTTP method, URI path, absolute HTTPS URL, and a brief description of its functionality. This serves as the definitive source of truth for the project's routing and interface architecture.
|
||||
|
||||
### Authentication Endpoints
|
||||
|
||||
| Method | Path | Absolute URL | Description |
|
||||
|--------|------|--------------|-------------|
|
||||
| POST | /api/auth/login | https://velocity.desineuron.in/api/auth/login | Authenticate a user with email/password and return JWT token |
|
||||
| GET | /api/auth/me | https://velocity.desineuron.in/api/auth/me | Get current authenticated user's profile information |
|
||||
| GET | /api/auth/users | https://velocity.desineuron.in/api/auth/users | List all active users in the system |
|
||||
| POST | /api/auth/profile/avatar | https://velocity.desineuron.in/api/auth/profile/avatar | Upload and update user's profile avatar image |
|
||||
|
||||
### CRM Endpoints
|
||||
|
||||
| Method | Path | Absolute URL | Description |
|
||||
|--------|------|--------------|-------------|
|
||||
| GET | /api/leads | https://velocity.desineuron.in/api/leads | List leads with pagination and filtering |
|
||||
| GET | /api/leads/{lead_id} | https://velocity.desineuron.in/api/leads/{lead_id} | Get detailed information for a specific lead |
|
||||
| GET | /api/kanban/board | https://velocity.desineuron.in/api/kanban/board | Retrieve the kanban board view of leads by stage |
|
||||
| GET | /api/chat-logs | https://velocity.desineuron.in/api/chat-logs | List chat logs for leads with optional lead filtering |
|
||||
| GET | /api/leads/demographics | https://velocity.desineuron.in/api/leads/demographics | Get demographic analytics for leads (source, qualification) |
|
||||
|
||||
### Analytics Endpoints
|
||||
|
||||
| Method | Path | Absolute URL | Description |
|
||||
|--------|------|--------------|-------------|
|
||||
| GET | /api/analytics/sentiment-scatter | https://velocity.desineuron.in/api/analytics/sentiment-scatter | Get scatter plot data for sentiment analysis |
|
||||
|
||||
### Oracle AI Intelligence Endpoints
|
||||
|
||||
| Method | Path | Absolute URL | Description |
|
||||
|--------|------|--------------|-------------|
|
||||
| GET | /api/oracle/health | https://velocity.desineuron.in/api/oracle/health | Check Oracle system health and MCP tool availability |
|
||||
| GET | /api/oracle/data-health | https://velocity.desineuron.in/api/oracle/data-health | Get data health metrics for database tables |
|
||||
| GET | /api/oracle/schema-catalog | https://velocity.desineuron.in/api/oracle/schema-catalog | Retrieve schema catalog for database introspection |
|
||||
| POST | /api/oracle/query | https://velocity.desineuron.in/api/oracle/query | Execute natural language query against database |
|
||||
| GET | /api/oracle/mcp/tools | https://velocity.desineuron.in/api/oracle/mcp/tools | List available MCP tools for execution |
|
||||
| POST | /api/oracle/mcp/execute | https://velocity.desineuron.in/api/oracle/mcp/execute | Execute an MCP tool with given query |
|
||||
| POST | /api/oracle/workflow/preview | https://velocity.desineuron.in/api/oracle/workflow/preview | Preview workflow plan for a natural language prompt |
|
||||
|
||||
#### Oracle Canvas Management (v1)
|
||||
|
||||
| Method | Path | Absolute URL | Description |
|
||||
|--------|------|--------------|-------------|
|
||||
| POST | /api/oracle/v1/canvas/pages | https://velocity.desineuron.in/api/oracle/v1/canvas/pages | Create a new Oracle canvas page |
|
||||
| GET | /api/oracle/v1/canvas/pages | https://velocity.desineuron.in/api/oracle/v1/canvas/pages | List user's Oracle canvas pages |
|
||||
| GET | /api/oracle/v1/canvas/pages/{page_id} | https://velocity.desineuron.in/api/oracle/v1/canvas/pages/{page_id} | Get a specific canvas page |
|
||||
| PUT | /api/oracle/v1/canvas/pages/{page_id} | https://velocity.desineuron.in/api/oracle/v1/canvas/pages/{page_id} | Update a canvas page |
|
||||
| DELETE | /api/oracle/v1/canvas/pages/{page_id} | https://velocity.desineuron.in/api/oracle/v1/canvas/pages/{page_id} | Delete a canvas page |
|
||||
| POST | /api/oracle/v1/canvas/pages/{page_id}/fork | https://velocity.desineuron.in/api/oracle/v1/canvas/pages/{page_id}/fork | Create a fork of a canvas page |
|
||||
| POST | /api/oracle/v1/canvas/pages/{page_id}/merge | https://velocity.desineuron.in/api/oracle/v1/canvas/pages/{page_id}/merge | Merge changes into a canvas page |
|
||||
| GET | /api/oracle/v1/canvas/pages/{page_id}/revisions | https://velocity.desineuron.in/api/oracle/v1/canvas/pages/{page_id}/revisions | List revisions for a canvas page |
|
||||
| POST | /api/oracle/v1/canvas/pages/{page_id}/revisions | https://velocity.desineuron.in/api/oracle/v1/canvas/pages/{page_id}/revisions | Create new revision for a canvas page |
|
||||
| GET | /api/oracle/v1/canvas/pages/{page_id}/revisions/{revision_id} | https://velocity.desineuron.in/api/oracle/v1/canvas/pages/{page_id}/revisions/{revision_id} | Get specific revision |
|
||||
| PUT | /api/oracle/v1/canvas/pages/{page_id}/revisions/{revision_id} | https://velocity.desineuron.in/api/oracle/v1/canvas/pages/{page_id}/revisions/{revision_id} | Update a revision |
|
||||
| DELETE | /api/oracle/v1/canvas/pages/{page_id}/revisions/{revision_id} | https://velocity.desineuron.in/api/oracle/v1/canvas/pages/{page_id}/revisions/{revision_id} | Delete a revision |
|
||||
| GET | /api/oracle/v1/canvas/pages/{page_id}/revisions/{revision_id}/components | https://velocity.desineuron.in/api/oracle/v1/canvas/pages/{page_id}/revisions/{revision_id}/components | List components in a revision |
|
||||
| POST | /api/oracle/v1/canvas/pages/{page_id}/revisions/{revision_id}/components | https://velocity.desineuron.in/api/oracle/v1/canvas/pages/{page_id}/revisions/{revision_id}/components | Add component to revision |
|
||||
| PUT | /api/oracle/v1/canvas/pages/{page_id}/revisions/{revision_id}/components/{component_id} | https://velocity.desineuron.in/api/oracle/v1/canvas/pages/{page_id}/revisions/{revision_id}/components/{component_id} | Update component |
|
||||
| DELETE | /api/oracle/v1/canvas/pages/{page_id}/revisions/{revision_id}/components/{component_id} | https://velocity.desineuron.in/api/oracle/v1/canvas/pages/{page_id}/revisions/{revision_id}/components/{component_id} | Delete component |
|
||||
|
||||
#### Oracle Template Management
|
||||
|
||||
| Method | Path | Absolute URL | Description |
|
||||
|--------|------|--------------|-------------|
|
||||
| GET | /api/oracle/template-chapters | https://velocity.desineuron.in/api/oracle/template-chapters | List Oracle template chapters |
|
||||
| POST | /api/oracle/template-chapters | https://velocity.desineuron.in/api/oracle/template-chapters | Create new template chapter |
|
||||
| GET | /api/oracle/template-subchapters | https://velocity.desineuron.in/api/oracle/template-subchapters | List Oracle template subchapters |
|
||||
| POST | /api/oracle/template-subchapters | https://velocity.desineuron.in/api/oracle/template-subchapters | Create new template subchapter |
|
||||
| GET | /api/oracle/component-templates | https://velocity.desineuron.in/api/oracle/component-templates | List Oracle component templates |
|
||||
| POST | /api/oracle/component-templates | https://velocity.desineuron.in/api/oracle/component-templates | Create new component template |
|
||||
| GET | /api/oracle/component-templates/{template_id} | https://velocity.desineuron.in/api/oracle/component-templates/{template_id} | Get specific component template |
|
||||
| POST | /api/oracle/component-templates/{template_id}/seed | https://velocity.desineuron.in/api/oracle/component-templates/{template_id}/seed | Add seed example to template |
|
||||
| GET | /api/oracle/component-templates/{template_id}/seed | https://velocity.desineuron.in/api/oracle/component-templates/{template_id}/seed | List seed examples for template |
|
||||
| POST | /api/oracle/component-templates/synthetic-jobs | https://velocity.desineuron.in/api/oracle/component-templates/synthetic-jobs | Submit synthetic template generation job |
|
||||
|
||||
### Sentinel Biometric Intelligence Endpoints
|
||||
|
||||
| Method | Path | Absolute URL | Description |
|
||||
|--------|------|--------------|-------------|
|
||||
| WebSocket | /api/sentinel/ws/notifications | wss://velocity.desineuron.in/api/sentinel/ws/notifications | Real-time notifications WebSocket |
|
||||
| WebSocket | /api/sentinel/ws/perception | wss://velocity.desineuron.in/api/sentinel/ws/perception | Biometric perception data WebSocket |
|
||||
| POST | /api/sentinel/consent | https://velocity.desineuron.in/api/sentinel/consent | Record biometric consent for lead |
|
||||
| POST | /api/sentinel/session/complete | https://velocity.desineuron.in/api/sentinel/session/complete | Close a perception session and finalize QD score |
|
||||
| POST | /api/sentinel/tag-lead | https://velocity.desineuron.in/api/sentinel/tag-lead | Apply NemoClaw lead tagging to CRM lead |
|
||||
| GET | /api/sentinel/qd-score/{lead_id} | https://velocity.desineuron.in/api/sentinel/qd-score/{lead_id} | Get current QD score for a lead |
|
||||
|
||||
### Catalyst Marketing Orchestration Endpoints
|
||||
|
||||
| Method | Path | Absolute URL | Description |
|
||||
|--------|------|--------------|-------------|
|
||||
| POST | /api/catalyst/campaigns/create | https://velocity.desineuron.in/api/catalyst/campaigns/create | Bulk create Meta ad campaigns |
|
||||
| POST | /api/catalyst/creative/sync | https://velocity.desineuron.in/api/catalyst/creative/sync | Upload ComfyUI assets to Meta |
|
||||
| GET | /api/catalyst/insights/realtime | https://velocity.desineuron.in/api/catalyst/insights/realtime | Poll Meta Ads Insights API |
|
||||
| POST | /api/catalyst/audiences/lookalike | https://velocity.desineuron.in/api/catalyst/audiences/lookalike | Push CRM leads to Meta Custom Audience |
|
||||
| POST | /api/catalyst/auth/meta | https://velocity.desineuron.in/api/catalyst/auth/meta | OAuth token acquisition for Meta |
|
||||
|
||||
### Vault Trackable Links Endpoints
|
||||
|
||||
| Method | Path | Absolute URL | Description |
|
||||
|--------|------|--------------|-------------|
|
||||
| POST | /api/vault/generate-link | https://velocity.desineuron.in/api/vault/generate-link | Generate trackable URL for shared asset |
|
||||
| GET | /vault/{tracking_hash} | https://velocity.desineuron.in/vault/{tracking_hash} | Public access to trackable vault link (no auth required) |
|
||||
|
||||
### CCTV Surveillance Integration Endpoints
|
||||
|
||||
| Method | Path | Absolute URL | Description |
|
||||
|--------|------|--------------|-------------|
|
||||
| POST | /api/cctv/event | https://velocity.desineuron.in/api/cctv/event | Ingest CCTV frame event from RTSP/ONVIF bridge |
|
||||
| POST | /api/cctv/finalize-auto-mode | https://velocity.desineuron.in/api/cctv/finalize-auto-mode | Match or create lead after auto-mode session |
|
||||
|
||||
### Video Scene Mapping Endpoints
|
||||
|
||||
| Method | Path | Absolute URL | Description |
|
||||
|--------|------|--------------|-------------|
|
||||
| POST | /api/scenes/upload | https://velocity.desineuron.in/api/scenes/upload | Upload CSV scene map for marketing video |
|
||||
| GET | /api/scenes/{video_asset_id} | https://velocity.desineuron.in/api/scenes/{video_asset_id} | Get scene map for specific video asset |
|
||||
|
||||
### Marketing Videos Endpoints
|
||||
|
||||
| Method | Path | Absolute URL | Description |
|
||||
|--------|------|--------------|-------------|
|
||||
| GET | /api/videos/marketing | https://velocity.desineuron.in/api/videos/marketing | List marketing videos available for Sentinel sessions |
|
||||
|
||||
### Mobile Edge Communication Endpoints
|
||||
|
||||
| Method | Path | Absolute URL | Description |
|
||||
|--------|------|--------------|-------------|
|
||||
| GET | /api/mobile-edge/events | https://velocity.desineuron.in/api/mobile-edge/events | List communication events for a lead |
|
||||
| POST | /api/mobile-edge/events | https://velocity.desineuron.in/api/mobile-edge/events | Log new communication event |
|
||||
| GET | /api/mobile-edge/memory | https://velocity.desineuron.in/api/mobile-edge/memory | List memory facts for a lead |
|
||||
| POST | /api/mobile-edge/imports | https://velocity.desineuron.in/api/mobile-edge/imports | Operator-assisted import of recording/note |
|
||||
| POST | /api/mobile-edge/notes | https://velocity.desineuron.in/api/mobile-edge/notes | Quick note attachment to lead |
|
||||
| GET | /api/mobile-edge/calendar | https://velocity.desineuron.in/api/mobile-edge/calendar | Calendar events for authenticated user |
|
||||
| POST | /api/mobile-edge/calendar | https://velocity.desineuron.in/api/mobile-edge/calendar | Create calendar event |
|
||||
| PATCH | /api/mobile-edge/calendar/{calendar_event_id} | https://velocity.desineuron.in/api/mobile-edge/calendar/{calendar_event_id} | Update calendar event |
|
||||
| DELETE | /api/mobile-edge/calendar/{calendar_event_id} | https://velocity.desineuron.in/api/mobile-edge/calendar/{calendar_event_id} | Cancel calendar event |
|
||||
| GET | /api/mobile-edge/transcripts/{event_id} | https://velocity.desineuron.in/api/mobile-edge/transcripts/{event_id} | Transcript segments for event |
|
||||
| GET | /api/mobile-edge/insights/{lead_id} | https://velocity.desineuron.in/api/mobile-edge/insights/{lead_id} | Insight recommendations for lead |
|
||||
| POST | /api/mobile-edge/insights/{recommendation_id}/act | https://velocity.desineuron.in/api/mobile-edge/insights/{recommendation_id}/act | Act on or dismiss insight |
|
||||
| GET | /api/mobile-edge/alerts | https://velocity.desineuron.in/api/mobile-edge/alerts | Active alerts for authenticated user |
|
||||
| POST | /api/mobile-edge/session | https://velocity.desineuron.in/api/mobile-edge/session | Register surface session heartbeat |
|
||||
|
||||
### Inventory Management Endpoints
|
||||
|
||||
| Method | Path | Absolute URL | Description |
|
||||
|--------|------|--------------|-------------|
|
||||
| POST | /api/inventory/import-batches | https://velocity.desineuron.in/api/inventory/import-batches | Create inventory import batch |
|
||||
| GET | /api/inventory/import-batches | https://velocity.desineuron.in/api/inventory/import-batches | List import batches |
|
||||
| GET | /api/inventory/import-batches/{batch_id} | https://velocity.desineuron.in/api/inventory/import-batches/{batch_id} | Get batch status |
|
||||
| POST | /api/inventory/properties | https://velocity.desineuron.in/api/inventory/properties | Create single property |
|
||||
| GET | /api/inventory/properties | https://velocity.desineuron.in/api/inventory/properties | List inventory properties |
|
||||
| GET | /api/inventory/properties/{property_id} | https://velocity.desineuron.in/api/inventory/properties/{property_id} | Get property details |
|
||||
| PATCH | /api/inventory/properties/{property_id} | https://velocity.desineuron.in/api/inventory/properties/{property_id} | Update property |
|
||||
| DELETE | /api/inventory/properties/{property_id} | https://velocity.desineuron.in/api/inventory/properties/{property_id} | Archive property |
|
||||
| POST | /api/inventory/properties/{property_id}/media | https://velocity.desineuron.in/api/inventory/properties/{property_id}/media | Attach media to property |
|
||||
| GET | /api/inventory/properties/{property_id}/media | https://velocity.desineuron.in/api/inventory/properties/{property_id}/media | List media for property |
|
||||
| DELETE | /api/inventory/media/{media_asset_id} | https://velocity.desineuron.in/api/inventory/media/{media_asset_id} | Remove media asset |
|
||||
|
||||
### Admin Surface Control Endpoints
|
||||
|
||||
| Method | Path | Absolute URL | Description |
|
||||
|--------|------|--------------|-------------|
|
||||
| GET | /api/admin-surface/health | https://velocity.desineuron.in/api/admin-surface/health | System health overview |
|
||||
| GET | /api/admin-surface/queues | https://velocity.desineuron.in/api/admin-surface/queues | Queue depth snapshot |
|
||||
| GET | /api/admin-surface/installs | https://velocity.desineuron.in/api/admin-surface/installs | Surface session/install overview |
|
||||
| POST | /api/admin-surface/actions | https://velocity.desineuron.in/api/admin-surface/actions | Submit admin action |
|
||||
| GET | /api/admin-surface/actions | https://velocity.desineuron.in/api/admin-surface/actions | List admin action history |
|
||||
| GET | /api/admin-surface/actions/{action_event_id} | https://velocity.desineuron.in/api/admin-surface/actions/{action_event_id} | Get specific admin action |
|
||||
| GET | /api/admin-surface/logs | https://velocity.desineuron.in/api/admin-surface/logs | Recent Oracle audit event log |
|
||||
| GET | /api/admin-surface/templates | https://velocity.desineuron.in/api/admin-surface/templates | Template catalog admin view |
|
||||
| POST | /api/admin-surface/templates/{template_id}/publish | https://velocity.desineuron.in/api/admin-surface/templates/{template_id}/publish | Publish template |
|
||||
| POST | /api/admin-surface/templates/{template_id}/archive | https://velocity.desineuron.in/api/admin-surface/templates/{template_id}/archive | Archive template |
|
||||
| GET | /api/admin-surface/template-chapters | https://velocity.desineuron.in/api/admin-surface/template-chapters | List template chapters (admin) |
|
||||
| GET | /api/admin-surface/synthetic-jobs | https://velocity.desineuron.in/api/admin-surface/synthetic-jobs | List synthetic generation jobs |
|
||||
| POST | /api/admin-surface/synthetic-jobs/{job_id}/cancel | https://velocity.desineuron.in/api/admin-surface/synthetic-jobs/{job_id}/cancel | Cancel synthetic job |
|
||||
|
||||
### CRM Canonical Data Endpoints
|
||||
|
||||
| Method | Path | Absolute URL | Description |
|
||||
|--------|------|--------------|-------------|
|
||||
| POST | /api/crm/imports | https://velocity.desineuron.in/api/crm/imports | Upload CSV batch for import |
|
||||
| GET | /api/crm/imports | https://velocity.desineuron.in/api/crm/imports | List import batches |
|
||||
| GET | /api/crm/imports/{batch_id} | https://velocity.desineuron.in/api/crm/imports/{batch_id} | Get batch detail and proposals |
|
||||
| PUT | /api/crm/imports/{batch_id}/review-proposal | https://velocity.desineuron.in/api/crm/imports/{batch_id}/review-proposal | Review import proposal |
|
||||
| POST | /api/crm/imports/{batch_id}/commit | https://velocity.desineuron.in/api/crm/imports/{batch_id}/commit | Commit approved proposals |
|
||||
| GET | /api/crm/contacts | https://velocity.desineuron.in/api/crm/contacts | Canonical contact list with QD summary |
|
||||
| POST | /api/crm/contacts | https://velocity.desineuron.in/api/crm/contacts | Create new contact |
|
||||
| GET | /api/crm/contacts/{person_id} | https://velocity.desineuron.in/api/crm/contacts/{person_id} | Canonical contact detail |
|
||||
| GET | /api/crm/client-360/{person_id} | https://velocity.desineuron.in/api/crm/client-360/{person_id} | Client 360 aggregated snapshot |
|
||||
| GET | /api/crm/opportunities | https://velocity.desineuron.in/api/crm/opportunities | Opportunity pipeline list |
|
||||
| GET | /api/crm/tasks | https://velocity.desineuron.in/api/crm/tasks | Reminder/task list |
|
||||
| POST | /api/crm/tasks | https://velocity.desineuron.in/api/crm/tasks | Create new task |
|
||||
| GET | /api/crm/kanban | https://velocity.desineuron.in/api/crm/kanban | Kanban board (canonical leads) |
|
||||
| GET | /api/crm/qd/{person_id} | https://velocity.desineuron.in/api/crm/qd/{person_id} | QD score history for person |
|
||||
| GET | /api/crm/client-data | https://velocity.desineuron.in/api/crm/client-data | List client data records |
|
||||
| GET | /api/crm/client-data/{person_id} | https://velocity.desineuron.in/api/crm/client-data/{person_id} | Get client data for person |
|
||||
| PATCH | /api/crm/client-data/{person_id} | https://velocity.desineuron.in/api/crm/client-data/{person_id} | Update client data |
|
||||
| GET | /api/crm/client-data/{person_id}/timeline | https://velocity.desineuron.in/api/crm/client-data/{person_id}/timeline | Client data timeline |
|
||||
| POST | /api/crm/client-data/{person_id}/tasks | https://velocity.desineuron.in/api/crm/client-data/{person_id}/tasks | Create task for client |
|
||||
|
||||
### Runtime LLM Endpoints
|
||||
Method Path Absolute URL Description
|
||||
GET /api/runtime/llm/providers https://velocity.desineuron.in/api/runtime/llm/providers List configured LLM providers and models
|
||||
POST /api/runtime/llm/chat https://velocity.desineuron.in/api/runtime/llm/chat Execute single LLM chat completion
|
||||
POST /api/runtime/llm/batch https://velocity.desineuron.in/api/runtime/llm/batch Submit persisted LLM batch job
|
||||
GET /api/runtime/llm/jobs/{job_id} https://velocity.desineuron.in/api/runtime/llm/jobs/{job_id} Get batch job status
|
||||
GET /api/runtime/llm/jobs/{job_id}/results https://velocity.desineuron.in/api/runtime/llm/jobs/{job_id}/results Get batch job results
|
||||
|
||||
### Infrastructure Notes
|
||||
|
||||
- **Caddy Reverse Proxy**: All endpoints are served through Caddy on port 443, proxying to FastAPI on localhost:8443 with TLS termination
|
||||
- **Authentication**: JWT-based auth required for most endpoints (except public vault links)
|
||||
- **WebSockets**: Real-time features use WebSocket connections for live updates
|
||||
- **Role-Based Access**: Endpoints enforce role permissions (SENIOR_BROKER, ADMIN, etc.)
|
||||
- **Tenant Isolation**: Multi-tenant architecture with tenant_id scoping
|
||||
- **Audit Logging**: All mutations create immutable audit records
|
||||
- **Health Checks**: System provides comprehensive health and queue monitoring endpoints
|
||||
File diff suppressed because it is too large
Load Diff
63
.github/workflows/production-readiness.yml
vendored
Normal file
63
.github/workflows/production-readiness.yml
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
name: Production Readiness
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
|
||||
jobs:
|
||||
backend-contracts:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Install backend dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r backend/requirements.txt
|
||||
pip install pytest
|
||||
- name: Run backend contract tests
|
||||
run: |
|
||||
PYTHONPATH="$PWD" python -m pytest \
|
||||
backend/tests/test_auth_tenant_contract.py \
|
||||
backend/tests/test_canonical_crm_auth.py \
|
||||
backend/tests/test_canonical_crm_tenant_scoping.py \
|
||||
backend/tests/test_dream_weaver_gateway_auth.py \
|
||||
backend/tests/test_migrations_and_observability.py \
|
||||
backend/tests/test_surface_route_tenant_scoping.py
|
||||
|
||||
webos-typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: app
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
cache-dependency-path: app/package-lock.json
|
||||
- name: Install WebOS dependencies
|
||||
run: npm ci
|
||||
- name: Typecheck WebOS
|
||||
run: npx tsc --noEmit
|
||||
|
||||
ipad-parse:
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Parse active iPad Swift sources
|
||||
run: |
|
||||
swiftc -frontend -parse \
|
||||
iOS/velocity-ipad/velocity/App/ContentView.swift \
|
||||
iOS/velocity-ipad/velocity/Features/Clients/ClientsView.swift \
|
||||
iOS/velocity-ipad/velocity/Features/Imports/ImportsView.swift \
|
||||
iOS/velocity-ipad/velocity/Core/Networking/VelocityAPIClient.swift \
|
||||
iOS/velocity-ipad/velocity/Core/State/AppStore.swift \
|
||||
iOS/velocity-ipad/velocityTests/VelocitySmokeTests.swift
|
||||
|
||||
29
app/dist/index.html
vendored
29
app/dist/index.html
vendored
@@ -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>
|
||||
124
app/node_modules/.tmp/tsconfig.app.tsbuildinfo
generated
vendored
124
app/node_modules/.tmp/tsconfig.app.tsbuildinfo
generated
vendored
@@ -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"
|
||||
}
|
||||
14
app/node_modules/.vite/deps/@radix-ui_react-avatar.js
generated
vendored
14
app/node_modules/.vite/deps/@radix-ui_react-avatar.js
generated
vendored
@@ -1,18 +1,18 @@
|
||||
"use client";
|
||||
import {
|
||||
require_shim
|
||||
} from "./chunk-TXHHHGR3.js";
|
||||
import {
|
||||
useCallbackRef,
|
||||
useLayoutEffect2
|
||||
} from "./chunk-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";
|
||||
|
||||
7
app/node_modules/.vite/deps/@radix-ui_react-avatar.js.map
generated
vendored
7
app/node_modules/.vite/deps/@radix-ui_react-avatar.js.map
generated
vendored
File diff suppressed because one or more lines are too long
908
app/node_modules/.vite/deps/@radix-ui_react-dropdown-menu.js
generated
vendored
908
app/node_modules/.vite/deps/@radix-ui_react-dropdown-menu.js
generated
vendored
File diff suppressed because it is too large
Load Diff
7
app/node_modules/.vite/deps/@radix-ui_react-dropdown-menu.js.map
generated
vendored
7
app/node_modules/.vite/deps/@radix-ui_react-dropdown-menu.js.map
generated
vendored
File diff suppressed because one or more lines are too long
17
app/node_modules/.vite/deps/@radix-ui_react-slot.js
generated
vendored
17
app/node_modules/.vite/deps/@radix-ui_react-slot.js
generated
vendored
@@ -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
|
||||
};
|
||||
7
app/node_modules/.vite/deps/@radix-ui_react-slot.js.map
generated
vendored
7
app/node_modules/.vite/deps/@radix-ui_react-slot.js.map
generated
vendored
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"version": 3,
|
||||
"sources": [],
|
||||
"sourcesContent": [],
|
||||
"mappings": "",
|
||||
"names": []
|
||||
}
|
||||
3704
app/node_modules/.vite/deps/@react-three_drei.js
generated
vendored
3704
app/node_modules/.vite/deps/@react-three_drei.js
generated
vendored
File diff suppressed because it is too large
Load Diff
7
app/node_modules/.vite/deps/@react-three_drei.js.map
generated
vendored
7
app/node_modules/.vite/deps/@react-three_drei.js.map
generated
vendored
File diff suppressed because one or more lines are too long
69
app/node_modules/.vite/deps/@react-three_fiber.js
generated
vendored
69
app/node_modules/.vite/deps/@react-three_fiber.js
generated
vendored
@@ -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
|
||||
};
|
||||
7
app/node_modules/.vite/deps/@react-three_fiber.js.map
generated
vendored
7
app/node_modules/.vite/deps/@react-three_fiber.js.map
generated
vendored
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"version": 3,
|
||||
"sources": [],
|
||||
"sourcesContent": [],
|
||||
"mappings": "",
|
||||
"names": []
|
||||
}
|
||||
385
app/node_modules/.vite/deps/_metadata.json
generated
vendored
385
app/node_modules/.vite/deps/_metadata.json
generated
vendored
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
35
app/node_modules/.vite/deps/chunk-G3PMV62Z.js
generated
vendored
35
app/node_modules/.vite/deps/chunk-G3PMV62Z.js
generated
vendored
@@ -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
|
||||
};
|
||||
7
app/node_modules/.vite/deps/chunk-G3PMV62Z.js.map
generated
vendored
7
app/node_modules/.vite/deps/chunk-G3PMV62Z.js.map
generated
vendored
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"version": 3,
|
||||
"sources": [],
|
||||
"sourcesContent": [],
|
||||
"mappings": "",
|
||||
"names": []
|
||||
}
|
||||
32999
app/node_modules/.vite/deps/chunk-OAEA5FZL.js
generated
vendored
32999
app/node_modules/.vite/deps/chunk-OAEA5FZL.js
generated
vendored
File diff suppressed because it is too large
Load Diff
7
app/node_modules/.vite/deps/chunk-OAEA5FZL.js.map
generated
vendored
7
app/node_modules/.vite/deps/chunk-OAEA5FZL.js.map
generated
vendored
File diff suppressed because one or more lines are too long
21
app/node_modules/.vite/deps/chunk-U7P2NEEE.js
generated
vendored
21
app/node_modules/.vite/deps/chunk-U7P2NEEE.js
generated
vendored
@@ -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
|
||||
7
app/node_modules/.vite/deps/chunk-U7P2NEEE.js.map
generated
vendored
7
app/node_modules/.vite/deps/chunk-U7P2NEEE.js.map
generated
vendored
@@ -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": []
|
||||
}
|
||||
51
app/node_modules/.vite/deps/class-variance-authority.js
generated
vendored
51
app/node_modules/.vite/deps/class-variance-authority.js
generated
vendored
@@ -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
|
||||
7
app/node_modules/.vite/deps/class-variance-authority.js.map
generated
vendored
7
app/node_modules/.vite/deps/class-variance-authority.js.map
generated
vendored
@@ -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"]
|
||||
}
|
||||
9
app/node_modules/.vite/deps/clsx.js
generated
vendored
9
app/node_modules/.vite/deps/clsx.js
generated
vendored
@@ -1,9 +0,0 @@
|
||||
import {
|
||||
clsx,
|
||||
clsx_default
|
||||
} from "./chunk-U7P2NEEE.js";
|
||||
import "./chunk-G3PMV62Z.js";
|
||||
export {
|
||||
clsx,
|
||||
clsx_default as default
|
||||
};
|
||||
7
app/node_modules/.vite/deps/clsx.js.map
generated
vendored
7
app/node_modules/.vite/deps/clsx.js.map
generated
vendored
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"version": 3,
|
||||
"sources": [],
|
||||
"sourcesContent": [],
|
||||
"mappings": "",
|
||||
"names": []
|
||||
}
|
||||
12319
app/node_modules/.vite/deps/framer-motion.js
generated
vendored
12319
app/node_modules/.vite/deps/framer-motion.js
generated
vendored
File diff suppressed because it is too large
Load Diff
7
app/node_modules/.vite/deps/framer-motion.js.map
generated
vendored
7
app/node_modules/.vite/deps/framer-motion.js.map
generated
vendored
File diff suppressed because one or more lines are too long
98
app/node_modules/.vite/deps/hls-Q6LDPZPT.js
generated
vendored
98
app/node_modules/.vite/deps/hls-Q6LDPZPT.js
generated
vendored
@@ -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
|
||||
};
|
||||
7
app/node_modules/.vite/deps/hls-Q6LDPZPT.js.map
generated
vendored
7
app/node_modules/.vite/deps/hls-Q6LDPZPT.js.map
generated
vendored
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"version": 3,
|
||||
"sources": [],
|
||||
"sourcesContent": [],
|
||||
"mappings": "",
|
||||
"names": []
|
||||
}
|
||||
27351
app/node_modules/.vite/deps/lucide-react.js
generated
vendored
27351
app/node_modules/.vite/deps/lucide-react.js
generated
vendored
File diff suppressed because it is too large
Load Diff
7
app/node_modules/.vite/deps/lucide-react.js.map
generated
vendored
7
app/node_modules/.vite/deps/lucide-react.js.map
generated
vendored
File diff suppressed because one or more lines are too long
264
app/node_modules/.vite/deps/react-dom.js
generated
vendored
264
app/node_modules/.vite/deps/react-dom.js
generated
vendored
@@ -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
|
||||
|
||||
6
app/node_modules/.vite/deps/react-dom.js.map
generated
vendored
6
app/node_modules/.vite/deps/react-dom.js.map
generated
vendored
File diff suppressed because one or more lines are too long
8
app/node_modules/.vite/deps/react-dom_client.js
generated
vendored
8
app/node_modules/.vite/deps/react-dom_client.js
generated
vendored
@@ -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();
|
||||
7
app/node_modules/.vite/deps/react-dom_client.js.map
generated
vendored
7
app/node_modules/.vite/deps/react-dom_client.js.map
generated
vendored
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"version": 3,
|
||||
"sources": [],
|
||||
"sourcesContent": [],
|
||||
"mappings": "",
|
||||
"names": []
|
||||
}
|
||||
3
app/node_modules/.vite/deps/react.js
generated
vendored
3
app/node_modules/.vite/deps/react.js
generated
vendored
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
require_react
|
||||
} from "./chunk-ZNKPWGXJ.js";
|
||||
import "./chunk-G3PMV62Z.js";
|
||||
} from "./chunk-E7O7WYRO.js";
|
||||
export default require_react();
|
||||
|
||||
6
app/node_modules/.vite/deps/react_jsx-dev-runtime.js
generated
vendored
6
app/node_modules/.vite/deps/react_jsx-dev-runtime.js
generated
vendored
@@ -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({
|
||||
|
||||
4
app/node_modules/.vite/deps/react_jsx-dev-runtime.js.map
generated
vendored
4
app/node_modules/.vite/deps/react_jsx-dev-runtime.js.map
generated
vendored
File diff suppressed because one or more lines are too long
276
app/node_modules/.vite/deps/react_jsx-runtime.js
generated
vendored
276
app/node_modules/.vite/deps/react_jsx-runtime.js
generated
vendored
@@ -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
|
||||
|
||||
6
app/node_modules/.vite/deps/react_jsx-runtime.js.map
generated
vendored
6
app/node_modules/.vite/deps/react_jsx-runtime.js.map
generated
vendored
File diff suppressed because one or more lines are too long
2506
app/node_modules/.vite/deps/recharts.js
generated
vendored
2506
app/node_modules/.vite/deps/recharts.js
generated
vendored
File diff suppressed because it is too large
Load Diff
7
app/node_modules/.vite/deps/recharts.js.map
generated
vendored
7
app/node_modules/.vite/deps/recharts.js.map
generated
vendored
File diff suppressed because one or more lines are too long
3095
app/node_modules/.vite/deps/tailwind-merge.js
generated
vendored
3095
app/node_modules/.vite/deps/tailwind-merge.js
generated
vendored
File diff suppressed because it is too large
Load Diff
7
app/node_modules/.vite/deps/tailwind-merge.js.map
generated
vendored
7
app/node_modules/.vite/deps/tailwind-merge.js.map
generated
vendored
File diff suppressed because one or more lines are too long
877
app/node_modules/.vite/deps/three.js
generated
vendored
877
app/node_modules/.vite/deps/three.js
generated
vendored
@@ -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
|
||||
};
|
||||
7
app/node_modules/.vite/deps/three.js.map
generated
vendored
7
app/node_modules/.vite/deps/three.js.map
generated
vendored
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"version": 3,
|
||||
"sources": [],
|
||||
"sourcesContent": [],
|
||||
"mappings": "",
|
||||
"names": []
|
||||
}
|
||||
14
app/node_modules/.vite/deps/zustand.js
generated
vendored
14
app/node_modules/.vite/deps/zustand.js
generated
vendored
@@ -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
|
||||
};
|
||||
7
app/node_modules/.vite/deps/zustand.js.map
generated
vendored
7
app/node_modules/.vite/deps/zustand.js.map
generated
vendored
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"version": 3,
|
||||
"sources": [],
|
||||
"sourcesContent": [],
|
||||
"mappings": "",
|
||||
"names": []
|
||||
}
|
||||
19
app/node_modules/.vite/deps/zustand_middleware.js
generated
vendored
19
app/node_modules/.vite/deps/zustand_middleware.js
generated
vendored
@@ -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
|
||||
};
|
||||
7
app/node_modules/.vite/deps/zustand_middleware.js.map
generated
vendored
7
app/node_modules/.vite/deps/zustand_middleware.js.map
generated
vendored
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"version": 3,
|
||||
"sources": [],
|
||||
"sourcesContent": [],
|
||||
"mappings": "",
|
||||
"names": []
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Zap, TrendingUp, Eye, MousePointerClick, DollarSign,
|
||||
Upload, Play, Image, Film, RefreshCw, ArrowRight, Plus, X,
|
||||
AlertTriangle, ArrowRightLeft, PlusCircle, SlidersHorizontal,
|
||||
Activity, Check, Link2,
|
||||
Activity, Check, Link2, WandSparkles,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
@@ -17,6 +17,7 @@ import { useCurrency } from '@/store/useCurrencyStore';
|
||||
import type { Campaign, MarketingAsset, LiveOptimizationEvent, LiveEventType } from '@/types';
|
||||
import { GroundTruthPicker } from './GroundTruthPicker';
|
||||
import { CatalystMarketingTab } from './CatalystMarketingTab';
|
||||
import { CatalystDreamWeaverTab } from './CatalystDreamWeaverTab';
|
||||
import type { GroundTruthSelection } from './GroundTruthPicker';
|
||||
|
||||
// ── Design tokens ─────────────────────────────────────────────────────────────
|
||||
@@ -917,7 +918,7 @@ function WarRoom() {
|
||||
// Tab Bar
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type TabId = 'studio' | 'command' | 'intelligence' | 'war-room' | 'marketing';
|
||||
type TabId = 'studio' | 'command' | 'intelligence' | 'war-room' | 'marketing' | 'dream-weaver';
|
||||
|
||||
const TABS: Array<{ id: TabId; label: string; icon: LucideIcon }> = [
|
||||
{ id: 'studio', label: 'The Studio', icon: Clapperboard },
|
||||
@@ -925,6 +926,7 @@ const TABS: Array<{ id: TabId; label: string; icon: LucideIcon }> = [
|
||||
{ id: 'intelligence', label: 'Intelligence & ROI', icon: BarChart3 },
|
||||
{ id: 'war-room', label: 'War Room', icon: Globe },
|
||||
{ id: 'marketing', label: 'Marketing', icon: TrendingUp },
|
||||
{ id: 'dream-weaver', label: 'Dream Weaver', icon: WandSparkles },
|
||||
];
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -940,6 +942,7 @@ export function Catalyst() {
|
||||
'intelligence': <IntelligenceROI />,
|
||||
'war-room': <WarRoom />,
|
||||
'marketing': <CatalystMarketingTab />,
|
||||
'dream-weaver': <CatalystDreamWeaverTab />,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -998,8 +1001,8 @@ export function Catalyst() {
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Live Optimization Feed — always visible */}
|
||||
<LiveOptimizationFeed />
|
||||
{/* Live Optimization Feed — hidden on Dream Weaver because generation has its own status surface. */}
|
||||
{activeTab !== 'dream-weaver' && <LiveOptimizationFeed />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
524
app/src/components/modules/CatalystDreamWeaverTab.tsx
Normal file
524
app/src/components/modules/CatalystDreamWeaverTab.tsx
Normal file
@@ -0,0 +1,524 @@
|
||||
import { useEffect, useRef, useState, type ChangeEvent, type CSSProperties } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Home,
|
||||
Image as ImageIcon,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Sparkles,
|
||||
Upload,
|
||||
WandSparkles,
|
||||
} from 'lucide-react';
|
||||
import { useMarketingStore } from '@/store/useMarketingStore';
|
||||
import {
|
||||
DREAM_WEAVER_URL,
|
||||
checkDreamWeaverHealth,
|
||||
fetchDreamWeaverResult,
|
||||
getDreamWeaverStatus,
|
||||
submitDreamWeaverJob,
|
||||
type DreamWeaverHealth,
|
||||
type DreamWeaverJobResponse,
|
||||
type DreamWeaverStatusResponse,
|
||||
} from '@/lib/dreamWeaverApi';
|
||||
|
||||
const GLASS: CSSProperties = {
|
||||
background: 'rgba(8, 10, 18, 0.82)',
|
||||
border: '1px solid rgba(59,130,246,0.14)',
|
||||
backdropFilter: 'blur(24px)',
|
||||
WebkitBackdropFilter: 'blur(24px)',
|
||||
boxShadow: '0 0 0 1px rgba(255,255,255,0.04), 0 4px 32px rgba(0,0,0,0.55)',
|
||||
};
|
||||
|
||||
const INNER: CSSProperties = {
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
border: '1px solid rgba(255,255,255,0.07)',
|
||||
};
|
||||
|
||||
const ROOM_TYPES = [
|
||||
{ id: 'bedroom', label: 'Bedroom' },
|
||||
{ id: 'living_room', label: 'Living Room' },
|
||||
{ id: 'bathroom', label: 'Bathroom' },
|
||||
{ id: 'kitchen', label: 'Kitchen' },
|
||||
{ id: 'dining_room', label: 'Dining Room' },
|
||||
{ id: 'home_office', label: 'Office' },
|
||||
{ id: 'hallway', label: 'Hallway' },
|
||||
{ id: 'balcony', label: 'Balcony' },
|
||||
] as const;
|
||||
|
||||
type ProcessingState = 'idle' | 'checking' | 'submitting' | 'rendering' | 'downloading';
|
||||
|
||||
interface DreamWeaverOutput {
|
||||
id: string;
|
||||
roomLabel: string;
|
||||
keywords: string;
|
||||
imageUrl: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function isReadyStatus(status: DreamWeaverStatusResponse) {
|
||||
const normalized = status.status?.toLowerCase() ?? '';
|
||||
return Boolean(status.ready) || ['ready', 'completed', 'complete', 'succeeded', 'success', 'finished'].includes(normalized);
|
||||
}
|
||||
|
||||
function isFailedStatus(status: DreamWeaverStatusResponse) {
|
||||
const normalized = status.status?.toLowerCase() ?? '';
|
||||
return ['failed', 'error', 'cancelled', 'canceled'].includes(normalized);
|
||||
}
|
||||
|
||||
function statusLabel(state: ProcessingState, health: DreamWeaverHealth | null) {
|
||||
if (state === 'checking') return 'Checking gateway';
|
||||
if (state === 'submitting') return 'Submitting render';
|
||||
if (state === 'rendering') return 'ComfyUI rendering';
|
||||
if (state === 'downloading') return 'Fetching result';
|
||||
if (!health) return 'Gateway unknown';
|
||||
if (health.online && health.routeMounted && health.comfyuiOnline === false) return 'Gateway online · ComfyUI offline';
|
||||
if (health.online && health.routeMounted && health.comfyuiOnline && health.checkpointReady === false) return 'Gateway online · Model missing';
|
||||
return health.online && health.routeMounted ? 'Gateway online' : 'Gateway offline';
|
||||
}
|
||||
|
||||
function ResultActions({ output }: { output: DreamWeaverOutput }) {
|
||||
function downloadResult() {
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = output.imageUrl;
|
||||
anchor.download = `dream-weaver-${output.id}.png`;
|
||||
anchor.click();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={downloadResult}
|
||||
className="h-9 w-9 rounded-xl flex items-center justify-center transition-colors hover:bg-white/10"
|
||||
style={INNER}
|
||||
title="Download generated image"
|
||||
>
|
||||
<Download className="w-4 h-4 text-white/75" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.open(output.imageUrl, '_blank', 'noopener,noreferrer')}
|
||||
className="h-9 w-9 rounded-xl flex items-center justify-center transition-colors hover:bg-white/10"
|
||||
style={INNER}
|
||||
title="Open generated image"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4 text-white/75" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CatalystDreamWeaverTab() {
|
||||
const { pushLiveEvent } = useMarketingStore();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const objectUrlsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const [sourceFile, setSourceFile] = useState<File | null>(null);
|
||||
const [sourcePreview, setSourcePreview] = useState<string | null>(null);
|
||||
const [selectedRoomType, setSelectedRoomType] = useState<(typeof ROOM_TYPES)[number]['id']>('bedroom');
|
||||
const [keywords, setKeywords] = useState('');
|
||||
const [health, setHealth] = useState<DreamWeaverHealth | null>(null);
|
||||
const [processingState, setProcessingState] = useState<ProcessingState>('checking');
|
||||
const [progress, setProgress] = useState('Checking Dream Weaver gateway...');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentOutput, setCurrentOutput] = useState<DreamWeaverOutput | null>(null);
|
||||
const [history, setHistory] = useState<DreamWeaverOutput[]>([]);
|
||||
|
||||
const isProcessing = processingState !== 'idle' && processingState !== 'checking';
|
||||
const roomLabel = ROOM_TYPES.find((room) => room.id === selectedRoomType)?.label ?? 'Bedroom';
|
||||
|
||||
useEffect(() => {
|
||||
void refreshHealth();
|
||||
return () => {
|
||||
objectUrlsRef.current.forEach((url) => URL.revokeObjectURL(url));
|
||||
objectUrlsRef.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
function rememberObjectUrl(blobOrFile: Blob) {
|
||||
const url = URL.createObjectURL(blobOrFile);
|
||||
objectUrlsRef.current.add(url);
|
||||
return url;
|
||||
}
|
||||
|
||||
function setSourceFromFile(file: File) {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
setError('Dream Weaver needs a source room image.');
|
||||
return;
|
||||
}
|
||||
const previewUrl = rememberObjectUrl(file);
|
||||
setSourceFile(file);
|
||||
setSourcePreview(previewUrl);
|
||||
setCurrentOutput(null);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
setSourceFromFile(file);
|
||||
}
|
||||
event.target.value = '';
|
||||
}
|
||||
|
||||
async function refreshHealth() {
|
||||
setProcessingState('checking');
|
||||
setProgress('Checking Dream Weaver gateway...');
|
||||
const nextHealth = await checkDreamWeaverHealth();
|
||||
setHealth(nextHealth);
|
||||
setProcessingState('idle');
|
||||
setProgress(nextHealth.online && nextHealth.routeMounted
|
||||
? nextHealth.comfyuiOnline === false
|
||||
? `Gateway is online and the Dream Weaver route is mounted. ComfyUI is offline${nextHealth.comfyuiUrl ? ` at ${nextHealth.comfyuiUrl}` : ''}.`
|
||||
: nextHealth.checkpointReady === false
|
||||
? `ComfyUI is online${nextHealth.comfyuiUrl ? ` at ${nextHealth.comfyuiUrl}` : ''}, but no checkpoint model is installed. Hydrate RealVisXL into ComfyUI/models/checkpoints.`
|
||||
: 'Gateway is online and the Dream Weaver route is mounted.'
|
||||
: nextHealth.detail ?? 'Dream Weaver gateway is not reachable.');
|
||||
}
|
||||
|
||||
async function pollUntilReady(job: DreamWeaverJobResponse) {
|
||||
let latestResultUrl = job.result_url;
|
||||
for (let attempt = 1; attempt <= 150; attempt += 1) {
|
||||
const status = await getDreamWeaverStatus(job);
|
||||
latestResultUrl = status.result_url ?? latestResultUrl;
|
||||
setProgress(status.status ? `Render ${status.status} · poll ${attempt}/150` : `Render queued · poll ${attempt}/150`);
|
||||
|
||||
if (isReadyStatus(status)) {
|
||||
return latestResultUrl;
|
||||
}
|
||||
if (isFailedStatus(status) || status.error) {
|
||||
throw new Error(status.error ?? `Dream Weaver render ${status.status ?? 'failed'}.`);
|
||||
}
|
||||
await sleep(2000);
|
||||
}
|
||||
throw new Error('Dream Weaver timed out after 5 minutes.');
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
if (!sourceFile || isProcessing) return;
|
||||
setError(null);
|
||||
setCurrentOutput(null);
|
||||
|
||||
try {
|
||||
setProcessingState('submitting');
|
||||
setProgress(`Submitting ${roomLabel.toLowerCase()} staging request...`);
|
||||
const job = await submitDreamWeaverJob({
|
||||
image: sourceFile,
|
||||
roomType: selectedRoomType,
|
||||
keywords,
|
||||
});
|
||||
|
||||
setProcessingState('rendering');
|
||||
setProgress(`Job ${job.job_id} accepted. Waiting for ComfyUI output...`);
|
||||
const resultUrl = await pollUntilReady(job);
|
||||
|
||||
setProcessingState('downloading');
|
||||
setProgress('Fetching generated image...');
|
||||
const resultBlob = await fetchDreamWeaverResult(job.job_id, resultUrl);
|
||||
const imageUrl = rememberObjectUrl(resultBlob);
|
||||
const output: DreamWeaverOutput = {
|
||||
id: job.job_id,
|
||||
roomLabel,
|
||||
keywords: keywords.trim(),
|
||||
imageUrl,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
setCurrentOutput(output);
|
||||
setHistory((items) => [output, ...items].slice(0, 6));
|
||||
setProgress('Dream Weaver render complete.');
|
||||
pushLiveEvent({
|
||||
id: `dw-${job.job_id}-${Date.now()}`,
|
||||
type: 'create',
|
||||
campaignName: 'Dream Weaver',
|
||||
message: `${roomLabel} staging render completed.`,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Dream Weaver render failed.';
|
||||
setError(message);
|
||||
setProgress(message);
|
||||
pushLiveEvent({
|
||||
id: `dw-error-${Date.now()}`,
|
||||
type: 'alert',
|
||||
campaignName: 'Dream Weaver',
|
||||
message,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
} finally {
|
||||
setProcessingState('idle');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<motion.div
|
||||
className="relative rounded-2xl p-5 overflow-hidden"
|
||||
style={GLASS}
|
||||
initial={{ opacity: 0, y: 16, scale: 0.97 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ duration: 0.35, ease: [0.4, 0, 0.2, 1] }}
|
||||
>
|
||||
<div className="absolute inset-x-0 top-0 h-px pointer-events-none"
|
||||
style={{ background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.10), transparent)' }} />
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white mb-0.5 flex items-center gap-2">
|
||||
<WandSparkles className="w-4 h-4 text-blue-400" /> Dream Weaver
|
||||
</h3>
|
||||
<p className="text-xs" style={{ color: 'rgba(148,163,184,0.55)' }}>
|
||||
Room image transformation pipeline using the same Dream Weaver gateway as the iPad app.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 rounded-xl px-3 py-2 text-xs" style={INNER}>
|
||||
<span className={`h-2 w-2 rounded-full ${health?.online && health.routeMounted ? 'bg-green-400' : 'bg-red-400'}`} />
|
||||
<span className="text-white/75">{statusLabel(processingState, health)}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refreshHealth()}
|
||||
className="h-9 w-9 rounded-xl flex items-center justify-center transition-colors hover:bg-white/10"
|
||||
style={INNER}
|
||||
title="Check Dream Weaver gateway"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 text-white/75 ${processingState === 'checking' ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-[0.95fr_1.05fr] gap-5">
|
||||
<motion.div
|
||||
className="relative rounded-2xl p-5 overflow-hidden"
|
||||
style={GLASS}
|
||||
initial={{ opacity: 0, y: 16, scale: 0.97 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ duration: 0.35, delay: 0.05, ease: [0.4, 0, 0.2, 1] }}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||
<ImageIcon className="w-4 h-4 text-blue-400" /> Source Room
|
||||
</h3>
|
||||
<p className="text-xs mt-1" style={{ color: 'rgba(148,163,184,0.55)' }}>
|
||||
Upload a ground-truth room photograph, choose the target room type, then add optional styling keywords.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
const file = event.dataTransfer.files?.[0];
|
||||
if (file) setSourceFromFile(file);
|
||||
}}
|
||||
className="relative w-full min-h-[300px] rounded-2xl overflow-hidden flex items-center justify-center text-left transition-colors hover:border-blue-400/40"
|
||||
style={{ ...INNER, background: sourcePreview ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.025)' }}
|
||||
>
|
||||
{sourcePreview ? (
|
||||
<img src={sourcePreview} alt="Selected source room" className="absolute inset-0 h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3 text-center px-6">
|
||||
<div className="h-12 w-12 rounded-2xl flex items-center justify-center" style={INNER}>
|
||||
<Upload className="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">Upload room image</p>
|
||||
<p className="text-xs mt-1" style={{ color: 'rgba(148,163,184,0.55)' }}>
|
||||
Click to browse or drop a photo here.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFileChange} />
|
||||
</button>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-widest mb-2" style={{ color: 'rgba(148,163,184,0.65)' }}>
|
||||
Room type
|
||||
</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{ROOM_TYPES.map((room) => {
|
||||
const selected = selectedRoomType === room.id;
|
||||
return (
|
||||
<button
|
||||
key={room.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedRoomType(room.id)}
|
||||
className="rounded-xl px-3 py-2 text-sm font-medium flex items-center gap-2 transition-colors"
|
||||
style={{
|
||||
background: selected ? 'rgba(59,130,246,0.18)' : 'rgba(255,255,255,0.04)',
|
||||
border: selected ? '1px solid rgba(59,130,246,0.38)' : '1px solid rgba(255,255,255,0.07)',
|
||||
color: selected ? '#fff' : 'rgba(226,232,240,0.72)',
|
||||
}}
|
||||
>
|
||||
<Home className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>{room.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium uppercase tracking-widest mb-2 block" style={{ color: 'rgba(148,163,184,0.65)' }}>
|
||||
Keywords
|
||||
</label>
|
||||
<textarea
|
||||
value={keywords}
|
||||
onChange={(event) => setKeywords(event.target.value)}
|
||||
placeholder="gold, marble, luxury, soft daylight"
|
||||
rows={3}
|
||||
className="w-full resize-none rounded-xl px-3 py-2 text-sm text-white placeholder-white/20 outline-none focus:border-blue-400/50"
|
||||
style={INNER}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.div
|
||||
className="rounded-xl p-3 flex items-start gap-2"
|
||||
style={{ background: 'rgba(239,68,68,0.12)', border: '1px solid rgba(239,68,68,0.25)' }}
|
||||
initial={{ opacity: 0, y: -6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
>
|
||||
<AlertTriangle className="w-4 h-4 text-red-400 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm text-red-200 leading-relaxed">{error}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 rounded-2xl p-3" style={INNER}>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-white truncate">{progress}</p>
|
||||
<p className="text-[11px] mt-1 truncate" style={{ color: 'rgba(148,163,184,0.5)' }}>
|
||||
Gateway: {DREAM_WEAVER_URL}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void generate()}
|
||||
disabled={!sourceFile || isProcessing || health?.routeMounted === false || health?.comfyuiOnline === false || health?.checkpointReady === false}
|
||||
className="h-11 px-5 rounded-xl text-sm font-semibold flex items-center justify-center gap-2 disabled:opacity-45 disabled:cursor-not-allowed transition-colors"
|
||||
style={{ background: 'hsl(var(--accent))', color: 'hsl(var(--accent-fg))' }}
|
||||
>
|
||||
{isProcessing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="relative rounded-2xl p-5 overflow-hidden"
|
||||
style={GLASS}
|
||||
initial={{ opacity: 0, y: 16, scale: 0.97 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ duration: 0.35, delay: 0.1, ease: [0.4, 0, 0.2, 1] }}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 mb-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||
<WandSparkles className="w-4 h-4 text-blue-400" /> Generated Staging
|
||||
</h3>
|
||||
<p className="text-xs mt-1" style={{ color: 'rgba(148,163,184,0.55)' }}>
|
||||
The result appears here as soon as the gateway marks the job ready.
|
||||
</p>
|
||||
</div>
|
||||
{currentOutput && <ResultActions output={currentOutput} />}
|
||||
</div>
|
||||
|
||||
<div className="relative min-h-[460px] rounded-2xl overflow-hidden flex items-center justify-center" style={INNER}>
|
||||
{currentOutput ? (
|
||||
<img src={currentOutput.imageUrl} alt={`${currentOutput.roomLabel} Dream Weaver result`} className="absolute inset-0 h-full w-full object-contain bg-black" />
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3 text-center px-8">
|
||||
<div className="h-14 w-14 rounded-2xl flex items-center justify-center" style={INNER}>
|
||||
{isProcessing ? <Loader2 className="h-6 w-6 text-blue-400 animate-spin" /> : <WandSparkles className="h-6 w-6 text-blue-400" />}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">{isProcessing ? 'Dream Weaver is rendering' : 'No generated image yet'}</p>
|
||||
<p className="text-xs mt-1 max-w-md" style={{ color: 'rgba(148,163,184,0.55)' }}>
|
||||
Upload a source image and generate a staging render to populate this canvas.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{history.length > 0 && (
|
||||
<div className="mt-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-xs font-medium uppercase tracking-widest" style={{ color: 'rgba(148,163,184,0.65)' }}>
|
||||
Recent renders
|
||||
</p>
|
||||
<span className="text-[11px]" style={{ color: 'rgba(148,163,184,0.45)' }}>{history.length}/6</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{history.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setCurrentOutput(item)}
|
||||
className="group rounded-xl overflow-hidden text-left transition-colors hover:border-blue-400/40"
|
||||
style={INNER}
|
||||
>
|
||||
<div className="aspect-[4/3] bg-black overflow-hidden">
|
||||
<img src={item.imageUrl} alt={item.roomLabel} className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-300" />
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold text-white">
|
||||
<Check className="w-3.5 h-3.5 text-green-400" />
|
||||
{item.roomLabel}
|
||||
</div>
|
||||
<p className="text-[11px] mt-1 truncate" style={{ color: 'rgba(148,163,184,0.55)' }}>
|
||||
{item.keywords || item.createdAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="relative rounded-2xl p-5 overflow-hidden"
|
||||
style={GLASS}
|
||||
initial={{ opacity: 0, y: 16, scale: 0.97 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ duration: 0.35, delay: 0.15, ease: [0.4, 0, 0.2, 1] }}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||
<Server className="w-4 h-4 text-blue-400" /> Gateway Contract
|
||||
</h3>
|
||||
<span className="text-xs font-medium" style={{ color: health?.routeMounted ? '#4ade80' : '#f87171' }}>
|
||||
{health?.routeMounted ? 'Route mounted' : 'Route not verified'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{['POST /dream-weaver', 'GET /dream-weaver/status/{job_id}', 'GET /dream-weaver/result/{job_id}'].map((endpoint) => (
|
||||
<div key={endpoint} className="rounded-xl p-3" style={INNER}>
|
||||
<p className="text-xs font-mono text-blue-200">{endpoint}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { buildVelocityHeaders } from '@/lib/velocitySession';
|
||||
|
||||
const rawApiBase = import.meta.env.VITE_API_URL?.trim();
|
||||
const DEPLOYED_BACKEND_ORIGIN = 'https://velocity.desineuron.in';
|
||||
|
||||
@@ -75,10 +77,17 @@ export interface MarketingCampaignSummary {
|
||||
|
||||
async function requestJson<T>(path: string): Promise<T> {
|
||||
const response = await fetch(`${API_URL}${path}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
headers: buildVelocityHeaders(undefined, false),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed: ${response.status}`);
|
||||
const body = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
typeof body?.detail === 'string'
|
||||
? body.detail
|
||||
: typeof body?.message === 'string'
|
||||
? body.message
|
||||
: `Request failed: ${response.status}`,
|
||||
);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
@@ -8,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<{
|
||||
|
||||
197
app/src/lib/dreamWeaverApi.ts
Normal file
197
app/src/lib/dreamWeaverApi.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { API_URL } from '@/lib/api';
|
||||
import { buildVelocityHeaders } from '@/lib/velocitySession';
|
||||
|
||||
const rawDreamWeaverBase = import.meta.env.VITE_DREAM_WEAVER_URL?.trim();
|
||||
const rawDreamWeaverApiKey = import.meta.env.VITE_DREAM_WEAVER_API_KEY?.trim();
|
||||
const LOCAL_DREAM_WEAVER_GATEWAY = 'http://127.0.0.1:8082';
|
||||
|
||||
export const DREAM_WEAVER_URL = (rawDreamWeaverBase && rawDreamWeaverBase.length > 0
|
||||
? rawDreamWeaverBase
|
||||
: import.meta.env.DEV
|
||||
? LOCAL_DREAM_WEAVER_GATEWAY
|
||||
: API_URL
|
||||
).replace(/\/$/, '');
|
||||
|
||||
export interface DreamWeaverHealth {
|
||||
online: boolean;
|
||||
routeMounted: boolean;
|
||||
status: string;
|
||||
comfyuiOnline?: boolean;
|
||||
comfyuiUrl?: string;
|
||||
checkpointReady?: boolean;
|
||||
checkpointCount?: number;
|
||||
availableCheckpoints?: string[];
|
||||
preferredCheckpoints?: string[];
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface DreamWeaverJobResponse {
|
||||
job_id: string;
|
||||
status?: string;
|
||||
poll_url?: string;
|
||||
result_url?: string;
|
||||
}
|
||||
|
||||
export interface DreamWeaverStatusResponse {
|
||||
status?: string;
|
||||
ready?: boolean;
|
||||
result_url?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SubmitDreamWeaverJobInput {
|
||||
image: File;
|
||||
roomType: string;
|
||||
keywords: string;
|
||||
}
|
||||
|
||||
function buildDreamWeaverHeaders(init?: HeadersInit): Headers {
|
||||
const headers = buildVelocityHeaders(init, false);
|
||||
if (rawDreamWeaverApiKey && !headers.has('X-Dream-Weaver-API-Key')) {
|
||||
headers.set('X-Dream-Weaver-API-Key', rawDreamWeaverApiKey);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function resolveDreamWeaverUrl(candidate: string | undefined, fallbackPath: string): string {
|
||||
const path = candidate && candidate.trim().length > 0 ? candidate.trim() : fallbackPath;
|
||||
if (/^https?:\/\//i.test(path)) {
|
||||
return path;
|
||||
}
|
||||
return `${DREAM_WEAVER_URL}${path.startsWith('/') ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
async function readErrorMessage(response: Response, fallback: string): Promise<string> {
|
||||
const body = await response.json().catch(() => null) as { detail?: unknown; message?: unknown; error?: unknown } | null;
|
||||
if (typeof body?.detail === 'string') return body.detail;
|
||||
if (typeof body?.message === 'string') return body.message;
|
||||
if (typeof body?.error === 'string') return body.error;
|
||||
const text = await response.text().catch(() => '');
|
||||
return text.trim() || fallback;
|
||||
}
|
||||
|
||||
async function requestDreamWeaverJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers: buildDreamWeaverHeaders(init?.headers),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response, `Dream Weaver request failed: ${response.status}`));
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function checkDreamWeaverHealth(): Promise<DreamWeaverHealth> {
|
||||
let status = 'offline';
|
||||
let detail: string | undefined;
|
||||
let comfyuiOnline: boolean | undefined;
|
||||
let comfyuiUrl: string | undefined;
|
||||
let checkpointReady: boolean | undefined;
|
||||
let checkpointCount: number | undefined;
|
||||
let availableCheckpoints: string[] | undefined;
|
||||
let preferredCheckpoints: string[] | undefined;
|
||||
let healthOk = false;
|
||||
|
||||
try {
|
||||
const response = await fetch(resolveDreamWeaverUrl(undefined, '/health'), {
|
||||
headers: buildDreamWeaverHeaders(),
|
||||
});
|
||||
const body = await response.json().catch(() => null) as {
|
||||
status?: unknown;
|
||||
detail?: unknown;
|
||||
comfyui?: unknown;
|
||||
comfyui_url?: unknown;
|
||||
comfyuiUrl?: unknown;
|
||||
checkpoint_ready?: unknown;
|
||||
checkpoint_count?: unknown;
|
||||
available_checkpoints?: unknown;
|
||||
preferred_checkpoints?: unknown;
|
||||
} | null;
|
||||
status = typeof body?.status === 'string' ? body.status : response.ok ? 'ok' : `HTTP ${response.status}`;
|
||||
detail = typeof body?.detail === 'string' ? body.detail : undefined;
|
||||
comfyuiOnline = typeof body?.comfyui === 'boolean' ? body.comfyui : undefined;
|
||||
comfyuiUrl = typeof body?.comfyui_url === 'string'
|
||||
? body.comfyui_url
|
||||
: typeof body?.comfyuiUrl === 'string'
|
||||
? body.comfyuiUrl
|
||||
: undefined;
|
||||
checkpointReady = typeof body?.checkpoint_ready === 'boolean' ? body.checkpoint_ready : undefined;
|
||||
checkpointCount = typeof body?.checkpoint_count === 'number' ? body.checkpoint_count : undefined;
|
||||
availableCheckpoints = Array.isArray(body?.available_checkpoints)
|
||||
? body.available_checkpoints.filter((item): item is string => typeof item === 'string')
|
||||
: undefined;
|
||||
preferredCheckpoints = Array.isArray(body?.preferred_checkpoints)
|
||||
? body.preferred_checkpoints.filter((item): item is string => typeof item === 'string')
|
||||
: undefined;
|
||||
healthOk = response.ok && ['ok', 'healthy', 'online'].includes(status.toLowerCase());
|
||||
} catch (error) {
|
||||
detail = error instanceof Error ? error.message : 'Unable to reach Dream Weaver gateway.';
|
||||
}
|
||||
|
||||
try {
|
||||
const probe = await fetch(resolveDreamWeaverUrl(undefined, '/dream-weaver/status/velocity-route-probe'), {
|
||||
headers: buildDreamWeaverHeaders(),
|
||||
});
|
||||
if (probe.ok) {
|
||||
return { online: healthOk, routeMounted: true, status, comfyuiOnline, comfyuiUrl, checkpointReady, checkpointCount, availableCheckpoints, preferredCheckpoints, detail };
|
||||
}
|
||||
const probeMessage = await readErrorMessage(probe, '');
|
||||
const expectedMissingJob = probe.status === 404 && /job|not found|missing/i.test(probeMessage);
|
||||
return {
|
||||
online: healthOk && expectedMissingJob,
|
||||
routeMounted: expectedMissingJob,
|
||||
status,
|
||||
comfyuiOnline,
|
||||
comfyuiUrl,
|
||||
checkpointReady,
|
||||
checkpointCount,
|
||||
availableCheckpoints,
|
||||
preferredCheckpoints,
|
||||
detail: detail ?? probeMessage,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
online: false,
|
||||
routeMounted: false,
|
||||
status,
|
||||
comfyuiOnline,
|
||||
comfyuiUrl,
|
||||
checkpointReady,
|
||||
checkpointCount,
|
||||
availableCheckpoints,
|
||||
preferredCheckpoints,
|
||||
detail: error instanceof Error ? error.message : detail,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function submitDreamWeaverJob(input: SubmitDreamWeaverJobInput): Promise<DreamWeaverJobResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append('image', input.image, input.image.name || 'room-source.jpg');
|
||||
formData.append('room_type', input.roomType);
|
||||
const trimmedKeywords = input.keywords.trim();
|
||||
if (trimmedKeywords.length > 0) {
|
||||
formData.append('keywords', trimmedKeywords);
|
||||
}
|
||||
|
||||
return requestDreamWeaverJson<DreamWeaverJobResponse>(resolveDreamWeaverUrl(undefined, '/dream-weaver'), {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getDreamWeaverStatus(job: Pick<DreamWeaverJobResponse, 'job_id' | 'poll_url'>): Promise<DreamWeaverStatusResponse> {
|
||||
return requestDreamWeaverJson<DreamWeaverStatusResponse>(
|
||||
resolveDreamWeaverUrl(job.poll_url, `/dream-weaver/status/${encodeURIComponent(job.job_id)}`),
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchDreamWeaverResult(jobId: string, resultUrl?: string): Promise<Blob> {
|
||||
const response = await fetch(resolveDreamWeaverUrl(resultUrl, `/dream-weaver/result/${encodeURIComponent(jobId)}`), {
|
||||
headers: buildDreamWeaverHeaders({ Accept: 'image/png,image/*,*/*' }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response, `Dream Weaver result failed: ${response.status}`));
|
||||
}
|
||||
return response.blob();
|
||||
}
|
||||
@@ -1,10 +1,19 @@
|
||||
import { API_URL } from '@/lib/api';
|
||||
|
||||
export const VELOCITY_TOKEN_KEY = 'velocity-api-token';
|
||||
import {
|
||||
buildVelocityHeaders,
|
||||
setVelocityToken,
|
||||
} from '@/lib/velocitySession';
|
||||
export {
|
||||
VELOCITY_TOKEN_KEY,
|
||||
clearVelocityToken,
|
||||
getVelocityToken,
|
||||
setVelocityToken,
|
||||
} from '@/lib/velocitySession';
|
||||
|
||||
export interface VelocityUserProfile {
|
||||
user_id: string;
|
||||
role: string;
|
||||
tenant_id?: string;
|
||||
full_name?: string | null;
|
||||
email?: string | null;
|
||||
avatar_url?: string | null;
|
||||
@@ -13,6 +22,7 @@ export interface VelocityUserProfile {
|
||||
export interface VelocityActiveUser {
|
||||
user_id: string;
|
||||
role: string;
|
||||
tenant_id?: string;
|
||||
full_name?: string | null;
|
||||
email?: string | null;
|
||||
avatar_url?: string | null;
|
||||
@@ -148,18 +158,7 @@ export interface InventoryPropertySummary {
|
||||
}
|
||||
|
||||
function buildHeaders(init?: HeadersInit, includeJson = true): Headers {
|
||||
const headers = new Headers(init);
|
||||
if (includeJson && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
if (!headers.has('Accept')) {
|
||||
headers.set('Accept', 'application/json');
|
||||
}
|
||||
const token = getVelocityToken();
|
||||
if (token && !headers.has('Authorization')) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
return headers;
|
||||
return buildVelocityHeaders(init, includeJson);
|
||||
}
|
||||
|
||||
async function platformFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
@@ -182,18 +181,6 @@ async function platformFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export function setVelocityToken(token: string) {
|
||||
localStorage.setItem(VELOCITY_TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
export function getVelocityToken(): string | null {
|
||||
return localStorage.getItem(VELOCITY_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function clearVelocityToken() {
|
||||
localStorage.removeItem(VELOCITY_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function normalizeVelocityRole(role: string | null | undefined): string {
|
||||
return (role ?? '').trim().toUpperCase();
|
||||
}
|
||||
|
||||
37
app/src/lib/velocitySession.ts
Normal file
37
app/src/lib/velocitySession.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export const VELOCITY_TOKEN_KEY = 'velocity-api-token';
|
||||
|
||||
export function getVelocityToken(): string | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
return window.localStorage.getItem(VELOCITY_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setVelocityToken(token: string) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(VELOCITY_TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
export function clearVelocityToken() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
window.localStorage.removeItem(VELOCITY_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function buildVelocityHeaders(init?: HeadersInit, includeJson = true): Headers {
|
||||
const headers = new Headers(init);
|
||||
if (includeJson && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
if (!headers.has('Accept')) {
|
||||
headers.set('Accept', 'application/json');
|
||||
}
|
||||
const token = getVelocityToken();
|
||||
if (token && !headers.has('Authorization')) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
OracleEnvelope,
|
||||
CanvasPageRevision,
|
||||
} from '../types/canvas';
|
||||
import { VELOCITY_TOKEN_KEY } from '@/lib/velocityPlatformClient';
|
||||
import { VELOCITY_TOKEN_KEY } from '@/lib/velocitySession';
|
||||
|
||||
function getBrowserOrigin(): string {
|
||||
return typeof window !== 'undefined' ? window.location.origin : '';
|
||||
|
||||
@@ -15,7 +15,7 @@ interface MarketingState {
|
||||
adInsights: AdInsight[];
|
||||
liveEvents: LiveOptimizationEvent[];
|
||||
settings: CatalystSettings;
|
||||
activeTab: 'studio' | 'command' | 'intelligence' | 'war-room' | 'marketing';
|
||||
activeTab: 'studio' | 'command' | 'intelligence' | 'war-room' | 'marketing' | 'dream-weaver';
|
||||
|
||||
// Actions
|
||||
addCampaign: (campaign: Campaign) => void;
|
||||
|
||||
@@ -69,6 +69,7 @@ export interface CrmOpportunityCard {
|
||||
probability: number | null;
|
||||
expected_close_date: string | null;
|
||||
next_action: string | null;
|
||||
notes?: string | null;
|
||||
project_id: string | null;
|
||||
unit_id: string | null;
|
||||
// When fetched from list endpoint, person-level fields are included
|
||||
@@ -109,6 +110,16 @@ export interface CrmTask {
|
||||
client_phone?: string;
|
||||
}
|
||||
|
||||
export interface CrmLeadStageUpdate {
|
||||
lead_id: string;
|
||||
person_id: string;
|
||||
status: CrmLeadStatus;
|
||||
budget_band: string | null;
|
||||
urgency: string | null;
|
||||
client_name?: string;
|
||||
client_phone?: string;
|
||||
}
|
||||
|
||||
// ── Property Interest ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface PropertyInterest {
|
||||
|
||||
@@ -25,7 +25,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
@@ -131,7 +131,7 @@ async def get_health(
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"database": {
|
||||
"connected": True,
|
||||
"latency_ms": db_latency_ms,
|
||||
@@ -191,7 +191,7 @@ async def get_queues(
|
||||
"synthetic_jobs": {r["status"]: r["count"] for r in synthetic_queue},
|
||||
"inventory_batches": {r["status"]: r["count"] for r in inventory_queue},
|
||||
"admin_actions": {r["status"]: r["count"] for r in admin_queue},
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@@ -215,7 +215,7 @@ async def get_installs(
|
||||
)
|
||||
return {
|
||||
"installs": [dict(r) for r in rows],
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
@@ -43,6 +43,10 @@ def _pool(request: Request):
|
||||
return pool
|
||||
|
||||
|
||||
def _tenant_scope(user) -> str:
|
||||
return user.tenant_id
|
||||
|
||||
|
||||
# ── Pydantic Models ───────────────────────────────────────────────────────────
|
||||
|
||||
VALID_SOURCE_TYPES = {"csv", "json", "api_push", "manual"}
|
||||
@@ -111,7 +115,7 @@ async def create_import_batch(
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING batch_id, status, created_at
|
||||
""",
|
||||
user.role, body.source_type, user.user_id, body.total_rows, body.source_file_ref,
|
||||
_tenant_scope(user), body.source_type, user.user_id, body.total_rows, body.source_file_ref,
|
||||
)
|
||||
return dict(row)
|
||||
|
||||
@@ -134,10 +138,10 @@ async def list_import_batches(
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
""",
|
||||
user.role, limit, offset,
|
||||
_tenant_scope(user), limit, offset,
|
||||
)
|
||||
total = await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM inventory_import_batches WHERE tenant_id=$1", user.role,
|
||||
"SELECT COUNT(*) FROM inventory_import_batches WHERE tenant_id=$1", _tenant_scope(user),
|
||||
)
|
||||
return {"total": total, "limit": limit, "offset": offset, "batches": [dict(r) for r in rows]}
|
||||
|
||||
@@ -154,7 +158,7 @@ async def get_import_batch(
|
||||
"""
|
||||
SELECT * FROM inventory_import_batches WHERE batch_id=$1 AND tenant_id=$2
|
||||
""",
|
||||
batch_id, user.role,
|
||||
batch_id, _tenant_scope(user),
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(404, "Batch not found")
|
||||
@@ -187,7 +191,7 @@ async def create_property(
|
||||
)
|
||||
RETURNING property_id, created_at
|
||||
""",
|
||||
user.role, body.batch_id, body.source_id, body.project_name, body.developer_name,
|
||||
_tenant_scope(user), body.batch_id, body.source_id, body.project_name, body.developer_name,
|
||||
json.dumps(body.location), body.property_type, json.dumps(body.price_bands),
|
||||
json.dumps(body.unit_mix), body.amenities,
|
||||
body.status, json.dumps(body.validation_state),
|
||||
@@ -207,7 +211,7 @@ async def list_properties(
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
where_clause = "WHERE tenant_id = $1"
|
||||
params: list[Any] = [user.role]
|
||||
params: list[Any] = [_tenant_scope(user)]
|
||||
idx = 2
|
||||
|
||||
if status_filter:
|
||||
@@ -246,7 +250,7 @@ async def get_property(
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT * FROM inventory_properties WHERE property_id=$1 AND tenant_id=$2",
|
||||
property_id, user.role,
|
||||
property_id, _tenant_scope(user),
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(404, "Property not found")
|
||||
@@ -287,8 +291,8 @@ async def update_property(
|
||||
if not updates:
|
||||
raise HTTPException(400, "No fields to update")
|
||||
|
||||
_add("updated_at", datetime.now(UTC))
|
||||
values.extend([property_id, user.role])
|
||||
_add("updated_at", datetime.now(timezone.utc))
|
||||
values.extend([property_id, _tenant_scope(user)])
|
||||
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
@@ -319,7 +323,7 @@ async def archive_property(
|
||||
SET status='archived', updated_at=NOW()
|
||||
WHERE property_id=$1 AND tenant_id=$2
|
||||
""",
|
||||
property_id, user.role,
|
||||
property_id, _tenant_scope(user),
|
||||
)
|
||||
if result == "UPDATE 0":
|
||||
raise HTTPException(404, "Property not found")
|
||||
@@ -344,7 +348,7 @@ async def add_media(
|
||||
# Verify property belongs to tenant
|
||||
exists = await conn.fetchval(
|
||||
"SELECT 1 FROM inventory_properties WHERE property_id=$1 AND tenant_id=$2",
|
||||
property_id, user.role,
|
||||
property_id, _tenant_scope(user),
|
||||
)
|
||||
if not exists:
|
||||
raise HTTPException(404, "Property not found")
|
||||
@@ -356,7 +360,7 @@ async def add_media(
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8)
|
||||
RETURNING media_asset_id, created_at
|
||||
""",
|
||||
property_id, user.role, body.media_type, body.url, body.thumbnail_url,
|
||||
property_id, _tenant_scope(user), body.media_type, body.url, body.thumbnail_url,
|
||||
body.sort_order, json.dumps(body.metadata), user.user_id,
|
||||
)
|
||||
return {"media_asset_id": str(row["media_asset_id"]), "created_at": str(row["created_at"])}
|
||||
@@ -377,7 +381,7 @@ async def list_media(
|
||||
WHERE property_id=$1 AND tenant_id=$2
|
||||
ORDER BY sort_order ASC, created_at ASC
|
||||
""",
|
||||
property_id, user.role,
|
||||
property_id, _tenant_scope(user),
|
||||
)
|
||||
return {"media": [dict(r) for r in rows]}
|
||||
|
||||
@@ -392,7 +396,7 @@ async def delete_media(
|
||||
async with pool.acquire() as conn:
|
||||
result = await conn.execute(
|
||||
"DELETE FROM inventory_media_assets WHERE media_asset_id=$1 AND tenant_id=$2",
|
||||
media_asset_id, user.role,
|
||||
media_asset_id, _tenant_scope(user),
|
||||
)
|
||||
if result == "DELETE 0":
|
||||
raise HTTPException(404, "Media asset not found")
|
||||
|
||||
@@ -24,7 +24,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
@@ -47,7 +47,11 @@ def _pool(request: Request):
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _tenant_scope(user) -> str:
|
||||
return user.tenant_id
|
||||
|
||||
|
||||
# ── Pydantic models ───────────────────────────────────────────────────────────
|
||||
@@ -63,6 +67,8 @@ VALID_DIRECTIONS = {"inbound", "outbound"}
|
||||
|
||||
VALID_CONSENT = {"unknown", "granted", "denied", "not_required"}
|
||||
|
||||
VALID_CALENDAR_STATUSES = {"tentative", "confirmed", "done", "cancelled"}
|
||||
|
||||
|
||||
class CommunicationEventCreate(BaseModel):
|
||||
lead_id: str
|
||||
@@ -102,6 +108,7 @@ class CalendarEventCreate(BaseModel):
|
||||
start_at: str # ISO8601
|
||||
end_at: str # ISO8601
|
||||
all_day: bool = False
|
||||
status: str = "confirmed"
|
||||
reminder_minutes: list[int] = Field(default_factory=lambda: [15])
|
||||
location: Optional[str] = None
|
||||
metadata: dict = Field(default_factory=dict)
|
||||
@@ -151,12 +158,12 @@ async def list_events(
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT $3 OFFSET $4
|
||||
""",
|
||||
user.role, # tenant_id derived from role scope; production uses dedicated tenant field
|
||||
_tenant_scope(user),
|
||||
lead_id, limit, offset,
|
||||
)
|
||||
total = await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM edge_communication_events WHERE tenant_id = $1 AND lead_id = $2",
|
||||
user.role, lead_id,
|
||||
_tenant_scope(user), lead_id,
|
||||
)
|
||||
return {
|
||||
"total": total,
|
||||
@@ -197,7 +204,7 @@ async def create_event(
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::jsonb)
|
||||
RETURNING event_id, created_at
|
||||
""",
|
||||
user.role, body.lead_id, body.channel, body.direction, body.provider,
|
||||
_tenant_scope(user), body.lead_id, body.channel, body.direction, body.provider,
|
||||
body.capture_mode, body.consent_state, body.duration_seconds,
|
||||
body.summary, body.raw_reference, body.recording_ref,
|
||||
json.dumps(body.provider_metadata),
|
||||
@@ -228,11 +235,11 @@ async def list_memory_facts(
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $3 OFFSET $4
|
||||
""",
|
||||
user.role, lead_id, limit, offset,
|
||||
_tenant_scope(user), lead_id, limit, offset,
|
||||
)
|
||||
total = await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM edge_communication_memory_facts WHERE tenant_id=$1 AND lead_id=$2",
|
||||
user.role, lead_id,
|
||||
_tenant_scope(user), lead_id,
|
||||
)
|
||||
return {"total": total, "limit": limit, "offset": offset, "facts": [dict(r) for r in rows]}
|
||||
|
||||
@@ -265,7 +272,7 @@ async def create_import(
|
||||
) VALUES ($1,$2,$3,'inbound',$4,$5,$6,$7)
|
||||
RETURNING event_id, created_at
|
||||
""",
|
||||
user.role, body.lead_id, body.channel, body.capture_mode,
|
||||
_tenant_scope(user), body.lead_id, body.channel, body.capture_mode,
|
||||
body.consent_state, body.recording_ref, body.summary,
|
||||
)
|
||||
event_id = event_row["event_id"]
|
||||
@@ -279,7 +286,7 @@ async def create_import(
|
||||
) VALUES ($1,$2,'audio',$3)
|
||||
RETURNING transcription_job_id
|
||||
""",
|
||||
user.role, event_id, body.consent_state,
|
||||
_tenant_scope(user), event_id, body.consent_state,
|
||||
)
|
||||
job_id = str(job_row["transcription_job_id"])
|
||||
|
||||
@@ -313,7 +320,7 @@ async def create_note(
|
||||
) VALUES ($1,$2,$3,$4,$5,'operator_note',1.0, TRUE)
|
||||
RETURNING fact_id, created_at
|
||||
""",
|
||||
user.role, body.lead_id, body.fact_type, body.note_text,
|
||||
_tenant_scope(user), body.lead_id, body.fact_type, body.note_text,
|
||||
body.effective_date,
|
||||
)
|
||||
return {"fact_id": str(row["fact_id"]), "created_at": str(row["created_at"])}
|
||||
@@ -338,10 +345,11 @@ async def list_calendar_events(
|
||||
all_day, status, reminder_minutes, created_by, location, metadata, created_at
|
||||
FROM user_calendar_events
|
||||
WHERE tenant_id=$1 AND owner_user_id=$2
|
||||
AND status <> 'cancelled'
|
||||
AND start_at >= $3::timestamptz AND end_at <= $4::timestamptz
|
||||
ORDER BY start_at ASC LIMIT $5
|
||||
""",
|
||||
user.role, user.user_id, from_date, to_date, limit,
|
||||
_tenant_scope(user), user.user_id, from_date, to_date, limit,
|
||||
)
|
||||
else:
|
||||
rows = await conn.fetch(
|
||||
@@ -350,9 +358,10 @@ async def list_calendar_events(
|
||||
all_day, status, reminder_minutes, created_by, location, metadata, created_at
|
||||
FROM user_calendar_events
|
||||
WHERE tenant_id=$1 AND owner_user_id=$2
|
||||
AND status <> 'cancelled'
|
||||
ORDER BY start_at ASC LIMIT $3
|
||||
""",
|
||||
user.role, user.user_id, limit,
|
||||
_tenant_scope(user), user.user_id, limit,
|
||||
)
|
||||
return {"events": [dict(r) for r in rows]}
|
||||
|
||||
@@ -365,21 +374,33 @@ async def create_calendar_event(
|
||||
):
|
||||
pool = _pool(request)
|
||||
import json
|
||||
if body.status not in VALID_CALENDAR_STATUSES:
|
||||
raise HTTPException(status_code=422, detail="Unsupported calendar status.")
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO user_calendar_events (
|
||||
tenant_id, owner_user_id, lead_id, source_event_id, title, description,
|
||||
start_at, end_at, all_day, reminder_minutes, created_by, location, metadata
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7::timestamptz,$8::timestamptz,$9,$10,$11,$12,$13::jsonb)
|
||||
RETURNING calendar_event_id, created_at
|
||||
start_at, end_at, all_day, status, reminder_minutes, created_by, location, metadata
|
||||
) VALUES (
|
||||
$1::text,$2::text,$3::text,$4::uuid,$5::text,$6::text,
|
||||
$7::timestamptz,$8::timestamptz,$9::boolean,$10::text,
|
||||
$11::integer[],$12::text,$13::text,$14::jsonb
|
||||
)
|
||||
RETURNING calendar_event_id, lead_id, title, description, start_at, end_at,
|
||||
all_day, status, reminder_minutes, created_by, location, metadata, created_at
|
||||
""",
|
||||
user.role, user.user_id, body.lead_id, body.source_event_id,
|
||||
_tenant_scope(user), user.user_id, body.lead_id, body.source_event_id,
|
||||
body.title, body.description, body.start_at, body.end_at,
|
||||
body.all_day, body.reminder_minutes, "user",
|
||||
body.all_day, body.status, body.reminder_minutes, "user",
|
||||
body.location, json.dumps(body.metadata),
|
||||
)
|
||||
return {"calendar_event_id": str(row["calendar_event_id"]), "created_at": str(row["created_at"])}
|
||||
event = dict(row)
|
||||
event["calendar_event_id"] = str(event["calendar_event_id"])
|
||||
for key in ("start_at", "end_at", "created_at"):
|
||||
if event.get(key) is not None and hasattr(event[key], "isoformat"):
|
||||
event[key] = event[key].isoformat()
|
||||
return {"status": "ok", "event": event}
|
||||
|
||||
|
||||
@router.patch("/calendar/{calendar_event_id}", summary="Update a calendar event")
|
||||
@@ -405,15 +426,18 @@ async def update_calendar_event(
|
||||
if body.description is not None: _add("description", body.description)
|
||||
if body.start_at is not None: _add("start_at", body.start_at)
|
||||
if body.end_at is not None: _add("end_at", body.end_at)
|
||||
if body.status is not None: _add("status", body.status)
|
||||
if body.status is not None:
|
||||
if body.status not in VALID_CALENDAR_STATUSES:
|
||||
raise HTTPException(status_code=422, detail="Unsupported calendar status.")
|
||||
_add("status", body.status)
|
||||
if body.reminder_minutes is not None: _add("reminder_minutes", body.reminder_minutes)
|
||||
if body.location is not None: _add("location", body.location)
|
||||
|
||||
if not updates:
|
||||
raise HTTPException(400, "No fields to update")
|
||||
|
||||
_add("updated_at", datetime.now(UTC))
|
||||
_add("tenant_id", user.role)
|
||||
_add("updated_at", datetime.now(timezone.utc))
|
||||
_add("tenant_id", _tenant_scope(user))
|
||||
_add("owner_user_id", user.user_id)
|
||||
values.append(calendar_event_id)
|
||||
|
||||
@@ -428,7 +452,7 @@ async def update_calendar_event(
|
||||
)
|
||||
if result == "UPDATE 0":
|
||||
raise HTTPException(404, "Calendar event not found or not owned by you")
|
||||
return {"status": "updated"}
|
||||
return {"status": "updated", "calendar_event_id": calendar_event_id}
|
||||
|
||||
|
||||
@router.delete("/calendar/{calendar_event_id}", summary="Cancel a calendar event")
|
||||
@@ -445,7 +469,7 @@ async def delete_calendar_event(
|
||||
SET status='cancelled', updated_at=NOW()
|
||||
WHERE tenant_id=$1 AND owner_user_id=$2 AND calendar_event_id=$3
|
||||
""",
|
||||
user.role, user.user_id, calendar_event_id,
|
||||
_tenant_scope(user), user.user_id, calendar_event_id,
|
||||
)
|
||||
if result == "UPDATE 0":
|
||||
raise HTTPException(404, "Calendar event not found or not owned by you")
|
||||
@@ -471,7 +495,7 @@ async def get_transcript(
|
||||
WHERE j.event_id = $1 AND e.tenant_id = $2
|
||||
ORDER BY j.created_at DESC LIMIT 1
|
||||
""",
|
||||
event_id, user.role,
|
||||
event_id, _tenant_scope(user),
|
||||
)
|
||||
if not job:
|
||||
raise HTTPException(404, "No transcription job found for this event")
|
||||
@@ -513,7 +537,7 @@ async def get_insights(
|
||||
WHERE tenant_id=$1 AND lead_id=$2 AND status=$3
|
||||
ORDER BY created_at DESC LIMIT $4
|
||||
""",
|
||||
user.role, lead_id, status_filter, limit,
|
||||
_tenant_scope(user), lead_id, status_filter, limit,
|
||||
)
|
||||
else:
|
||||
rows = await conn.fetch(
|
||||
@@ -524,7 +548,7 @@ async def get_insights(
|
||||
WHERE tenant_id=$1 AND lead_id=$2
|
||||
ORDER BY created_at DESC LIMIT $3
|
||||
""",
|
||||
user.role, lead_id, limit,
|
||||
_tenant_scope(user), lead_id, limit,
|
||||
)
|
||||
return {"insights": [dict(r) for r in rows]}
|
||||
|
||||
@@ -544,7 +568,7 @@ async def act_on_insight(
|
||||
SET status=$1, acted_by=$2, acted_at=NOW(), updated_at=NOW()
|
||||
WHERE recommendation_id=$3 AND tenant_id=$4
|
||||
""",
|
||||
body.action, user.user_id, recommendation_id, user.role,
|
||||
body.action, user.user_id, recommendation_id, _tenant_scope(user),
|
||||
)
|
||||
if result == "UPDATE 0":
|
||||
raise HTTPException(404, "Insight not found")
|
||||
@@ -568,7 +592,7 @@ async def get_alerts(
|
||||
async with pool.acquire() as conn:
|
||||
pending_insights = await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM insight_recommendations WHERE tenant_id=$1 AND status='pending'",
|
||||
user.role,
|
||||
_tenant_scope(user),
|
||||
)
|
||||
upcoming_events = await conn.fetchval(
|
||||
"""
|
||||
@@ -577,11 +601,11 @@ async def get_alerts(
|
||||
AND status='confirmed'
|
||||
AND start_at BETWEEN NOW() AND NOW() + INTERVAL '24 hours'
|
||||
""",
|
||||
user.role, user.user_id,
|
||||
_tenant_scope(user), user.user_id,
|
||||
)
|
||||
pending_transcriptions = await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM edge_transcription_jobs WHERE tenant_id=$1 AND status='pending'",
|
||||
user.role,
|
||||
_tenant_scope(user),
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -620,7 +644,7 @@ async def session_heartbeat(
|
||||
ORDER BY last_active_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
user.role, user.user_id, body.surface_type,
|
||||
_tenant_scope(user), user.user_id, body.surface_type,
|
||||
)
|
||||
|
||||
if existing_session_id:
|
||||
@@ -652,7 +676,7 @@ async def session_heartbeat(
|
||||
END
|
||||
)
|
||||
""",
|
||||
user.role, user.user_id, body.surface_type, body.app_version,
|
||||
_tenant_scope(user), user.user_id, body.surface_type, body.app_version,
|
||||
json.dumps(body.metadata), body.screen,
|
||||
)
|
||||
return {"status": "ok", "timestamp": _now()}
|
||||
|
||||
24
backend/api/routes_observability.py
Normal file
24
backend/api/routes_observability.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
|
||||
from backend.auth.dependencies import UserPrincipal, get_current_user
|
||||
from backend.observability import metrics_snapshot
|
||||
|
||||
router = APIRouter(prefix="/observability", tags=["Observability"])
|
||||
|
||||
|
||||
@router.get("/request-metrics")
|
||||
async def request_metrics(
|
||||
request: Request,
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
user: UserPrincipal = Depends(get_current_user),
|
||||
) -> dict:
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"tenant_id": user.tenant_id,
|
||||
"metrics": metrics_snapshot(request.app, limit=limit),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -29,6 +29,10 @@ ROLE_HIERARCHY = {
|
||||
"ADMIN": 3,
|
||||
}
|
||||
|
||||
|
||||
def default_tenant_id() -> str:
|
||||
return os.getenv("VELOCITY_DEFAULT_TENANT_ID", "tenant_velocity").strip() or "tenant_velocity"
|
||||
|
||||
# ── Password hashing ──────────────────────────────────────────────────────────
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
@@ -57,12 +61,14 @@ JWT_ALGORITHM = "HS256"
|
||||
JWT_EXPIRE_HOURS = 8
|
||||
|
||||
|
||||
def create_access_token(user_id: str, role: str) -> str:
|
||||
def create_access_token(user_id: str, role: str, tenant_id: Optional[str] = None) -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRE_HOURS)
|
||||
normalized_role = role.strip().upper()
|
||||
normalized_tenant = (tenant_id or default_tenant_id()).strip() or default_tenant_id()
|
||||
payload = {
|
||||
"sub": user_id,
|
||||
"role": normalized_role,
|
||||
"tenant_id": normalized_tenant,
|
||||
"exp": expire,
|
||||
"iat": datetime.now(timezone.utc),
|
||||
}
|
||||
@@ -75,6 +81,7 @@ def create_access_token(user_id: str, role: str) -> str:
|
||||
class UserPrincipal:
|
||||
user_id: str
|
||||
role: str
|
||||
tenant_id: str = default_tenant_id()
|
||||
|
||||
@property
|
||||
def role_level(self) -> int:
|
||||
@@ -112,7 +119,11 @@ def get_current_user(
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
) from exc
|
||||
|
||||
return UserPrincipal(user_id=payload["sub"], role=str(payload["role"]).strip().upper())
|
||||
return UserPrincipal(
|
||||
user_id=payload["sub"],
|
||||
role=str(payload["role"]).strip().upper(),
|
||||
tenant_id=str(payload.get("tenant_id") or default_tenant_id()).strip() or default_tenant_id(),
|
||||
)
|
||||
|
||||
|
||||
# ── Dependency factory: role gate ─────────────────────────────────────────────
|
||||
|
||||
105
backend/auth/routes.py
Normal file
105
backend/auth/routes.py
Normal file
@@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.auth.dependencies import UserPrincipal, get_current_user
|
||||
from backend.auth.service import (
|
||||
list_tenant_users,
|
||||
login_with_directory,
|
||||
read_authenticated_user_profile,
|
||||
)
|
||||
from backend.auth.user_directory import ensure_user_directory_schema
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
ASSET_DIR = os.getenv("VELOCITY_ASSET_DIR", "/opt/dlami/nvme/assets")
|
||||
|
||||
|
||||
def _sanitize_filename(value: str) -> str:
|
||||
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", value).strip("._")
|
||||
return cleaned or "upload"
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
|
||||
|
||||
@router.post("/api/auth/login", tags=["Auth"])
|
||||
async def login(body: LoginRequest, request: Request):
|
||||
"""
|
||||
Authenticate a user and return a JWT.
|
||||
Credentials are verified against the users_and_roles table.
|
||||
"""
|
||||
return await login_with_directory(
|
||||
app=request.app,
|
||||
email=body.email,
|
||||
password=body.password,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/auth/me", tags=["Auth"])
|
||||
async def me(request: Request, user: UserPrincipal = Depends(get_current_user)):
|
||||
return await read_authenticated_user_profile(app=request.app, user=user)
|
||||
|
||||
|
||||
@router.get("/api/auth/users", tags=["Auth"])
|
||||
async def list_auth_users(request: Request, user: UserPrincipal = Depends(get_current_user)):
|
||||
return await list_tenant_users(app=request.app, user=user)
|
||||
|
||||
|
||||
@router.post("/api/auth/profile/avatar", tags=["Auth"])
|
||||
async def upload_profile_avatar(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
user: UserPrincipal = Depends(get_current_user),
|
||||
):
|
||||
await ensure_user_directory_schema(request.app)
|
||||
pool = getattr(request.app.state, "db_pool", None)
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="Database unavailable.")
|
||||
|
||||
allowed = {"image/png", "image/jpeg", "image/jpg", "image/webp"}
|
||||
if file.content_type not in allowed:
|
||||
raise HTTPException(status_code=400, detail="Unsupported avatar format.")
|
||||
|
||||
extension = Path(file.filename or "avatar.png").suffix.lower() or ".png"
|
||||
if extension not in {".png", ".jpg", ".jpeg", ".webp"}:
|
||||
extension = ".png"
|
||||
|
||||
avatar_dir = Path(ASSET_DIR) / "profile_avatars"
|
||||
avatar_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
filename = (
|
||||
f"{user.user_id}_{_sanitize_filename(Path(file.filename or 'avatar').stem)}_"
|
||||
f"{int(datetime.now(timezone.utc).timestamp())}{extension}"
|
||||
)
|
||||
destination = avatar_dir / filename
|
||||
contents = await file.read()
|
||||
destination.write_bytes(contents)
|
||||
|
||||
avatar_url = f"/assets/profile_avatars/{filename}"
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
result = await conn.execute(
|
||||
"""
|
||||
UPDATE users_and_roles
|
||||
SET avatar_url = $2
|
||||
WHERE id = $1::uuid
|
||||
AND tenant_id = $3
|
||||
""",
|
||||
user.user_id,
|
||||
avatar_url,
|
||||
user.tenant_id,
|
||||
)
|
||||
|
||||
if result == "UPDATE 0":
|
||||
raise HTTPException(status_code=404, detail="Authenticated user profile was not found.")
|
||||
|
||||
return {"avatar_url": avatar_url}
|
||||
123
backend/auth/service.py
Normal file
123
backend/auth/service.py
Normal file
@@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from backend.auth.dependencies import (
|
||||
UserPrincipal,
|
||||
create_access_token,
|
||||
default_tenant_id,
|
||||
verify_password,
|
||||
)
|
||||
from backend.auth.user_directory import ensure_user_directory_schema
|
||||
|
||||
|
||||
async def _get_pool(app: Any):
|
||||
pool = getattr(app.state, "db_pool", None)
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="Database unavailable.")
|
||||
return pool
|
||||
|
||||
|
||||
async def login_with_directory(*, app: Any, email: str, password: str) -> dict[str, Any]:
|
||||
await ensure_user_directory_schema(app)
|
||||
pool = await _get_pool(app)
|
||||
tenant_fallback = default_tenant_id()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT
|
||||
id::text,
|
||||
role,
|
||||
password_hash,
|
||||
COALESCE(NULLIF(tenant_id, ''), $2) AS tenant_id
|
||||
FROM users_and_roles
|
||||
WHERE email = $1 AND is_active = TRUE
|
||||
""",
|
||||
email.strip(),
|
||||
tenant_fallback,
|
||||
)
|
||||
|
||||
if not row or not verify_password(password, row["password_hash"]):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid email or password.",
|
||||
)
|
||||
|
||||
token = create_access_token(
|
||||
user_id=row["id"],
|
||||
role=row["role"],
|
||||
tenant_id=row["tenant_id"],
|
||||
)
|
||||
return {"access_token": token, "token_type": "bearer", "expires_in": 28800}
|
||||
|
||||
|
||||
async def read_authenticated_user_profile(*, app: Any, user: UserPrincipal) -> dict[str, Any]:
|
||||
await ensure_user_directory_schema(app)
|
||||
pool = await _get_pool(app)
|
||||
tenant_scope = user.tenant_id or default_tenant_id()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT
|
||||
full_name,
|
||||
email,
|
||||
avatar_url,
|
||||
COALESCE(NULLIF(tenant_id, ''), $2) AS tenant_id
|
||||
FROM users_and_roles
|
||||
WHERE id = $1::uuid
|
||||
AND COALESCE(NULLIF(tenant_id, ''), $2) = $2
|
||||
""",
|
||||
user.user_id,
|
||||
tenant_scope,
|
||||
)
|
||||
|
||||
return {
|
||||
"user_id": user.user_id,
|
||||
"role": user.role,
|
||||
"tenant_id": row["tenant_id"] if row else tenant_scope,
|
||||
"full_name": row["full_name"] if row else None,
|
||||
"email": row["email"] if row else None,
|
||||
"avatar_url": row["avatar_url"] if row else None,
|
||||
}
|
||||
|
||||
|
||||
async def list_tenant_users(*, app: Any, user: UserPrincipal) -> list[dict[str, Any]]:
|
||||
await ensure_user_directory_schema(app)
|
||||
pool = await _get_pool(app)
|
||||
tenant_scope = user.tenant_id or default_tenant_id()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT
|
||||
id::text AS user_id,
|
||||
role,
|
||||
COALESCE(NULLIF(tenant_id, ''), $1) AS tenant_id,
|
||||
full_name,
|
||||
email,
|
||||
avatar_url
|
||||
FROM users_and_roles
|
||||
WHERE is_active = TRUE
|
||||
AND COALESCE(NULLIF(tenant_id, ''), $1) = $2
|
||||
ORDER BY
|
||||
COALESCE(NULLIF(full_name, ''), email, id::text) ASC
|
||||
""",
|
||||
default_tenant_id(),
|
||||
tenant_scope,
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"user_id": row["user_id"],
|
||||
"role": row["role"],
|
||||
"tenant_id": row["tenant_id"],
|
||||
"full_name": row["full_name"],
|
||||
"email": row["email"],
|
||||
"avatar_url": row["avatar_url"],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
45
backend/auth/user_directory.py
Normal file
45
backend/auth/user_directory.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from backend.auth.dependencies import default_tenant_id
|
||||
|
||||
_AUTH_USER_DIRECTORY_SCHEMA_CACHE_KEY = "_auth_user_directory_schema_ready"
|
||||
|
||||
|
||||
def _sql_text_literal(value: str) -> str:
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
|
||||
|
||||
async def ensure_user_directory_schema(app: Any) -> None:
|
||||
if getattr(app.state, _AUTH_USER_DIRECTORY_SCHEMA_CACHE_KEY, False):
|
||||
return
|
||||
|
||||
pool = getattr(app.state, "db_pool", None)
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="Database unavailable.")
|
||||
|
||||
tenant_fallback = default_tenant_id()
|
||||
tenant_default_literal = _sql_text_literal(tenant_fallback)
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute("ALTER TABLE users_and_roles ADD COLUMN IF NOT EXISTS tenant_id TEXT")
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE users_and_roles
|
||||
SET tenant_id = $1
|
||||
WHERE tenant_id IS NULL OR tenant_id = ''
|
||||
""",
|
||||
tenant_fallback,
|
||||
)
|
||||
await conn.execute(
|
||||
f"ALTER TABLE users_and_roles ALTER COLUMN tenant_id SET DEFAULT {tenant_default_literal}"
|
||||
)
|
||||
await conn.execute("ALTER TABLE users_and_roles ALTER COLUMN tenant_id SET NOT NULL")
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_users_tenant_active ON users_and_roles (tenant_id, is_active)"
|
||||
)
|
||||
|
||||
setattr(app.state, _AUTH_USER_DIRECTORY_SCHEMA_CACHE_KEY, True)
|
||||
88
backend/crm/canonical_schema.py
Normal file
88
backend/crm/canonical_schema.py
Normal file
@@ -0,0 +1,88 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from backend.auth.dependencies import default_tenant_id
|
||||
|
||||
_CANONICAL_CRM_SCHEMA_CACHE_KEY = "_canonical_crm_schema_ready"
|
||||
|
||||
|
||||
def _sql_text_literal(value: str) -> str:
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
|
||||
|
||||
async def ensure_canonical_crm_schema(app: Any) -> None:
|
||||
if getattr(app.state, _CANONICAL_CRM_SCHEMA_CACHE_KEY, False):
|
||||
return
|
||||
|
||||
pool = getattr(app.state, "db_pool", None)
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="Database unavailable.")
|
||||
|
||||
tenant_fallback = default_tenant_id()
|
||||
tenant_default_literal = _sql_text_literal(tenant_fallback)
|
||||
tenant_tables = (
|
||||
"crm_people",
|
||||
"crm_accounts",
|
||||
"crm_leads",
|
||||
"crm_opportunities",
|
||||
"crm_property_interests",
|
||||
"intel_interactions",
|
||||
"intel_reminders",
|
||||
"intel_qd_scores",
|
||||
"intel_qd_timeseries",
|
||||
"workflow_actions",
|
||||
"workflow_approvals",
|
||||
"workflow_import_batches",
|
||||
)
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
for table_name in tenant_tables:
|
||||
await conn.execute(f"ALTER TABLE {table_name} ADD COLUMN IF NOT EXISTS tenant_id TEXT")
|
||||
await conn.execute(
|
||||
f"""
|
||||
UPDATE {table_name}
|
||||
SET tenant_id = $1
|
||||
WHERE tenant_id IS NULL OR tenant_id = ''
|
||||
""",
|
||||
tenant_fallback,
|
||||
)
|
||||
await conn.execute(
|
||||
f"ALTER TABLE {table_name} ALTER COLUMN tenant_id SET DEFAULT {tenant_default_literal}"
|
||||
)
|
||||
await conn.execute(f"ALTER TABLE {table_name} ALTER COLUMN tenant_id SET NOT NULL")
|
||||
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_crm_people_tenant_created ON crm_people (tenant_id, created_at DESC)"
|
||||
)
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_crm_leads_tenant_status ON crm_leads (tenant_id, status, updated_at DESC)"
|
||||
)
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_crm_opportunities_tenant_stage ON crm_opportunities (tenant_id, stage, updated_at DESC)"
|
||||
)
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_crm_property_interests_tenant_person ON crm_property_interests (tenant_id, person_id, created_at DESC)"
|
||||
)
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_intel_interactions_tenant_person ON intel_interactions (tenant_id, person_id, happened_at DESC)"
|
||||
)
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_intel_reminders_tenant_status ON intel_reminders (tenant_id, status, due_at)"
|
||||
)
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_intel_qd_scores_tenant_person ON intel_qd_scores (tenant_id, person_id, score_type)"
|
||||
)
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_intel_qd_timeseries_tenant_person ON intel_qd_timeseries (tenant_id, person_id, timestamp DESC)"
|
||||
)
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_workflow_actions_tenant_status ON workflow_actions (tenant_id, status, created_at DESC)"
|
||||
)
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_workflow_import_batches_tenant_lifecycle ON workflow_import_batches (tenant_id, lifecycle, created_at DESC)"
|
||||
)
|
||||
|
||||
setattr(app.state, _CANONICAL_CRM_SCHEMA_CACHE_KEY, True)
|
||||
@@ -59,6 +59,7 @@ CREATE TABLE IF NOT EXISTS users_and_roles (
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role role_enum NOT NULL DEFAULT 'JUNIOR_BROKER',
|
||||
tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity',
|
||||
full_name TEXT,
|
||||
avatar_url TEXT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
@@ -68,6 +69,7 @@ CREATE TABLE IF NOT EXISTS users_and_roles (
|
||||
|
||||
-- Index for login lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users_and_roles (email);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_tenant_active ON users_and_roles (tenant_id, is_active);
|
||||
|
||||
-- ────────────────────────────────────────────────────────────────────────────
|
||||
-- TABLE: leads_intelligence (CRM core with QD scoring)
|
||||
|
||||
@@ -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
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
172
backend/main.py
172
backend/main.py
@@ -11,13 +11,12 @@ import os
|
||||
import json
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Set
|
||||
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, HTTPException, status, UploadFile, File
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from dotenv import load_dotenv
|
||||
@@ -62,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(),
|
||||
}
|
||||
|
||||
|
||||
2
backend/migrations/__init__.py
Normal file
2
backend/migrations/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Velocity backend migration utilities."""
|
||||
|
||||
102
backend/migrations/runner.py
Normal file
102
backend/migrations/runner.py
Normal file
@@ -0,0 +1,102 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
MIGRATIONS_DIR = Path(__file__).resolve().parent / "versions"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Migration:
|
||||
version: str
|
||||
name: str
|
||||
path: Path
|
||||
checksum: str
|
||||
sql: str
|
||||
|
||||
|
||||
def _checksum(sql: str) -> str:
|
||||
return hashlib.sha256(sql.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def discover_migrations(directory: Path = MIGRATIONS_DIR) -> list[Migration]:
|
||||
if not directory.exists():
|
||||
return []
|
||||
|
||||
migrations: list[Migration] = []
|
||||
for path in sorted(directory.glob("*.sql")):
|
||||
version, _, name = path.stem.partition("_")
|
||||
if not version or not name:
|
||||
raise ValueError(f"Invalid migration filename: {path.name}")
|
||||
sql = path.read_text(encoding="utf-8")
|
||||
migrations.append(
|
||||
Migration(
|
||||
version=version,
|
||||
name=name,
|
||||
path=path,
|
||||
checksum=_checksum(sql),
|
||||
sql=sql,
|
||||
)
|
||||
)
|
||||
|
||||
seen: set[str] = set()
|
||||
for migration in migrations:
|
||||
if migration.version in seen:
|
||||
raise ValueError(f"Duplicate migration version: {migration.version}")
|
||||
seen.add(migration.version)
|
||||
return migrations
|
||||
|
||||
|
||||
async def ensure_migration_table(conn) -> None:
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
checksum TEXT NOT NULL,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def applied_versions(conn) -> dict[str, str]:
|
||||
await ensure_migration_table(conn)
|
||||
rows = await conn.fetch("SELECT version, checksum FROM schema_migrations")
|
||||
return {row["version"]: row["checksum"] for row in rows}
|
||||
|
||||
|
||||
async def apply_migrations(conn, migrations: Iterable[Migration] | None = None) -> list[str]:
|
||||
pending = list(migrations if migrations is not None else discover_migrations())
|
||||
applied = await applied_versions(conn)
|
||||
applied_now: list[str] = []
|
||||
|
||||
for migration in pending:
|
||||
existing_checksum = applied.get(migration.version)
|
||||
if existing_checksum == migration.checksum:
|
||||
continue
|
||||
if existing_checksum and existing_checksum != migration.checksum:
|
||||
raise RuntimeError(
|
||||
f"Migration checksum mismatch for {migration.version}; "
|
||||
"create a new migration instead of editing an applied one."
|
||||
)
|
||||
|
||||
transaction = conn.transaction()
|
||||
async with transaction:
|
||||
await conn.execute(migration.sql)
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO schema_migrations (version, name, checksum)
|
||||
VALUES ($1, $2, $3)
|
||||
""",
|
||||
migration.version,
|
||||
migration.name,
|
||||
migration.checksum,
|
||||
)
|
||||
applied_now.append(migration.version)
|
||||
|
||||
return applied_now
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Velocity production observability foundation.
|
||||
-- Creates a lightweight table for durable request/error telemetry when enabled.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_request_events (
|
||||
event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
request_id TEXT NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
status_code INTEGER NOT NULL,
|
||||
duration_ms DOUBLE PRECISION NOT NULL,
|
||||
tenant_id TEXT,
|
||||
user_id UUID,
|
||||
error_type TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_app_request_events_created_at
|
||||
ON app_request_events (created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_app_request_events_path_status
|
||||
ON app_request_events (path, status_code, created_at DESC);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_calendar_events (
|
||||
calendar_event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id TEXT NOT NULL,
|
||||
owner_user_id TEXT NOT NULL,
|
||||
lead_id TEXT,
|
||||
source_event_id UUID,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
start_at TIMESTAMPTZ NOT NULL,
|
||||
end_at TIMESTAMPTZ NOT NULL,
|
||||
all_day BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
status TEXT NOT NULL DEFAULT 'confirmed'
|
||||
CHECK (status IN ('tentative', 'confirmed', 'done', 'cancelled')),
|
||||
reminder_minutes INTEGER[] NOT NULL DEFAULT '{15}'::INTEGER[],
|
||||
created_by TEXT NOT NULL DEFAULT 'user'
|
||||
CHECK (created_by IN ('user', 'nemoclaw_suggested', 'operator_import')),
|
||||
is_nemoclaw_confirmed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
location TEXT,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_calendar_events_owner
|
||||
ON user_calendar_events (tenant_id, owner_user_id, start_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_calendar_events_lead
|
||||
ON user_calendar_events (tenant_id, lead_id, start_at);
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE user_calendar_events
|
||||
DROP CONSTRAINT IF EXISTS user_calendar_events_status_check;
|
||||
|
||||
ALTER TABLE user_calendar_events
|
||||
ADD CONSTRAINT user_calendar_events_status_check
|
||||
CHECK (status IN ('tentative', 'confirmed', 'done', 'cancelled'));
|
||||
103
backend/observability.py
Normal file
103
backend/observability.py
Normal file
@@ -0,0 +1,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from collections import deque
|
||||
from dataclasses import asdict, dataclass
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
logger = logging.getLogger("velocity.observability")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RequestMetric:
|
||||
request_id: str
|
||||
method: str
|
||||
path: str
|
||||
status_code: int
|
||||
duration_ms: float
|
||||
|
||||
|
||||
class RequestObservabilityMiddleware(BaseHTTPMiddleware):
|
||||
def __init__(self, app, *, max_metrics: int = 500) -> None:
|
||||
super().__init__(app)
|
||||
self.max_metrics = max_metrics
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
request_id = request.headers.get("x-request-id") or str(uuid.uuid4())
|
||||
request.state.request_id = request_id
|
||||
started = time.perf_counter()
|
||||
status_code = 500
|
||||
|
||||
try:
|
||||
response = await call_next(request)
|
||||
status_code = response.status_code
|
||||
return self._finalize(request, response, request_id, started, status_code)
|
||||
except Exception:
|
||||
duration_ms = (time.perf_counter() - started) * 1000
|
||||
self._record_metric(request, request_id, status_code, duration_ms)
|
||||
logger.exception(
|
||||
"request_failed",
|
||||
extra={
|
||||
"request_id": request_id,
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"duration_ms": round(duration_ms, 2),
|
||||
},
|
||||
)
|
||||
raise
|
||||
|
||||
def _finalize(
|
||||
self,
|
||||
request: Request,
|
||||
response: Response,
|
||||
request_id: str,
|
||||
started: float,
|
||||
status_code: int,
|
||||
) -> Response:
|
||||
duration_ms = (time.perf_counter() - started) * 1000
|
||||
response.headers["X-Request-ID"] = request_id
|
||||
response.headers["X-Response-Time-Ms"] = f"{duration_ms:.2f}"
|
||||
self._record_metric(request, request_id, status_code, duration_ms)
|
||||
logger.info(
|
||||
"request_completed",
|
||||
extra={
|
||||
"request_id": request_id,
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"status_code": status_code,
|
||||
"duration_ms": round(duration_ms, 2),
|
||||
},
|
||||
)
|
||||
return response
|
||||
|
||||
def _record_metric(
|
||||
self,
|
||||
request: Request,
|
||||
request_id: str,
|
||||
status_code: int,
|
||||
duration_ms: float,
|
||||
) -> None:
|
||||
metrics = getattr(request.app.state, "request_metrics", None)
|
||||
if metrics is None:
|
||||
metrics = deque(maxlen=self.max_metrics)
|
||||
request.app.state.request_metrics = metrics
|
||||
metrics.append(
|
||||
RequestMetric(
|
||||
request_id=request_id,
|
||||
method=request.method,
|
||||
path=request.url.path,
|
||||
status_code=status_code,
|
||||
duration_ms=round(duration_ms, 2),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def metrics_snapshot(app, *, limit: int = 50) -> list[dict]:
|
||||
metrics = getattr(app.state, "request_metrics", deque())
|
||||
return [asdict(metric) for metric in list(metrics)[-limit:]][::-1]
|
||||
|
||||
@@ -223,7 +223,7 @@ CREATE TABLE IF NOT EXISTS user_calendar_events (
|
||||
end_at TIMESTAMPTZ NOT NULL,
|
||||
all_day BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
status TEXT NOT NULL DEFAULT 'confirmed'
|
||||
CHECK (status IN ('tentative','confirmed','cancelled')),
|
||||
CHECK (status IN ('tentative','confirmed','done','cancelled')),
|
||||
reminder_minutes INTEGER[] NOT NULL DEFAULT '{15}'::INTEGER[],
|
||||
created_by TEXT NOT NULL
|
||||
CHECK (created_by IN ('user','nemoclaw_suggested','operator_import')),
|
||||
|
||||
850
backend/scripts/seed_ipad_investor_demo.py
Normal file
850
backend/scripts/seed_ipad_investor_demo.py
Normal file
@@ -0,0 +1,850 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Seed realistic, idempotent iPad investor-demo data into the current operator tenant.
|
||||
|
||||
The script writes only canonical Velocity domains used by the iPad app:
|
||||
crm_*, intel_*, workflow_*, inventory_*, mobile-edge events, and calendar events.
|
||||
Rows are tagged with metadata_json/source identifiers so they are auditable and safe
|
||||
to re-run without duplication.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
try:
|
||||
import asyncpg
|
||||
except ModuleNotFoundError: # pragma: no cover - exercised by operator environment
|
||||
asyncpg = None # type: ignore[assignment]
|
||||
|
||||
|
||||
SEED_SOURCE = "velocity_ipad_investor_demo_2026_04"
|
||||
DEFAULT_OPERATOR_EMAIL = "sayan@desineuron.in"
|
||||
NAMESPACE = uuid.uuid5(uuid.NAMESPACE_URL, "https://desineuron.in/project-velocity/ipad-investor-demo")
|
||||
|
||||
|
||||
def _load_env() -> None:
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
for candidate in (repo_root / "backend" / ".env", repo_root / ".env"):
|
||||
if candidate.exists():
|
||||
load_dotenv(candidate, override=False)
|
||||
load_dotenv(override=False)
|
||||
|
||||
|
||||
def _stable_uuid(tenant_id: str, key: str) -> str:
|
||||
return str(uuid.uuid5(NAMESPACE, f"{tenant_id}:{key}"))
|
||||
|
||||
|
||||
def _json(value: Any) -> str:
|
||||
return json.dumps(value, separators=(",", ":"), ensure_ascii=True)
|
||||
|
||||
|
||||
def _db_kwargs() -> dict[str, Any]:
|
||||
if os.getenv("DATABASE_URL"):
|
||||
return {"dsn": os.environ["DATABASE_URL"]}
|
||||
required = ["VELOCITY_DB_NAME", "VELOCITY_DB_USER", "VELOCITY_DB_PASSWORD"]
|
||||
missing = [key for key in required if not os.getenv(key)]
|
||||
if missing:
|
||||
raise RuntimeError(
|
||||
"Missing database configuration: "
|
||||
+ ", ".join(missing)
|
||||
+ ". Set DATABASE_URL or VELOCITY_DB_* variables before running the seed."
|
||||
)
|
||||
return {
|
||||
"host": os.getenv("VELOCITY_DB_HOST", "localhost"),
|
||||
"port": int(os.getenv("VELOCITY_DB_PORT", "5432")),
|
||||
"database": os.environ["VELOCITY_DB_NAME"],
|
||||
"user": os.environ["VELOCITY_DB_USER"],
|
||||
"password": os.environ["VELOCITY_DB_PASSWORD"],
|
||||
}
|
||||
|
||||
|
||||
def _expected_counts() -> dict[str, int]:
|
||||
return {
|
||||
"people": len(DEMO_CLIENTS),
|
||||
"leads": len(DEMO_CLIENTS),
|
||||
"projects": len(PROJECTS),
|
||||
"properties": len(PROJECTS),
|
||||
"interests": len(DEMO_CLIENTS),
|
||||
"opportunities": len(DEMO_CLIENTS),
|
||||
"scores": len(DEMO_CLIENTS) * 3,
|
||||
"interactions": len(DEMO_CLIENTS) * 3,
|
||||
"edge_events": len(DEMO_CLIENTS),
|
||||
"reminders": len(DEMO_CLIENTS),
|
||||
"calendar_events": len(DEMO_CLIENTS),
|
||||
"import_batches": 1,
|
||||
"import_proposals": 3,
|
||||
}
|
||||
|
||||
|
||||
async def _resolve_operator(conn: asyncpg.Connection, email: str, tenant_override: str | None) -> tuple[str, str | None]:
|
||||
if tenant_override:
|
||||
user_id = await conn.fetchval(
|
||||
"SELECT id::text FROM users_and_roles WHERE email = $1 AND tenant_id = $2 LIMIT 1",
|
||||
email,
|
||||
tenant_override,
|
||||
)
|
||||
return tenant_override, user_id
|
||||
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT id::text AS user_id, tenant_id
|
||||
FROM users_and_roles
|
||||
WHERE email = $1
|
||||
AND is_active = TRUE
|
||||
ORDER BY last_login DESC NULLS LAST, created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
email,
|
||||
)
|
||||
if row:
|
||||
return row["tenant_id"], row["user_id"]
|
||||
tenant_id = os.getenv("VELOCITY_DEFAULT_TENANT_ID", "tenant_velocity")
|
||||
return tenant_id, None
|
||||
|
||||
|
||||
DEMO_CLIENTS = [
|
||||
{
|
||||
"key": "meera-sen",
|
||||
"name": "Meera Sen",
|
||||
"email": "meera.sen.investor@demo.desineuron.in",
|
||||
"phone": "+91-98745-11820",
|
||||
"buyer_type": "hni_end_user",
|
||||
"city": "Kolkata",
|
||||
"nationality": "Indian",
|
||||
"persona": ["family_upgrader", "waterfront_preference"],
|
||||
"budget": "12-18 Cr",
|
||||
"urgency": "high",
|
||||
"status": "qualified",
|
||||
"project": "Atri Aqua Sky Residences",
|
||||
"configuration": "4BHK Sky Villa",
|
||||
"unit": "Tower A / High floor",
|
||||
"budget_min": 120000000,
|
||||
"budget_max": 180000000,
|
||||
"stage": "proposal",
|
||||
"value": 154000000,
|
||||
"probability": 72,
|
||||
"next_action": "Share revised payment schedule and club-deck walkthrough slots.",
|
||||
"scores": (0.91, 0.86, 0.88),
|
||||
},
|
||||
{
|
||||
"key": "arjun-malhotra",
|
||||
"name": "Arjun Malhotra",
|
||||
"email": "arjun.malhotra.nri@demo.desineuron.in",
|
||||
"phone": "+91-98102-44591",
|
||||
"buyer_type": "nri_investor",
|
||||
"city": "Dubai",
|
||||
"nationality": "Indian",
|
||||
"persona": ["nri_portfolio_buyer", "rental_yield_focused"],
|
||||
"budget": "8-12 Cr",
|
||||
"urgency": "medium",
|
||||
"status": "site_visit_scheduled",
|
||||
"project": "Alipore Azure Residences",
|
||||
"configuration": "3BHK Signature",
|
||||
"unit": "Tower B / 21st floor",
|
||||
"budget_min": 80000000,
|
||||
"budget_max": 120000000,
|
||||
"stage": "site_visit",
|
||||
"value": 98000000,
|
||||
"probability": 61,
|
||||
"next_action": "Coordinate Saturday family video walkthrough and NRI remittance checklist.",
|
||||
"scores": (0.82, 0.79, 0.63),
|
||||
},
|
||||
{
|
||||
"key": "devika-roy",
|
||||
"name": "Devika Roy",
|
||||
"email": "devika.roy.familyoffice@demo.desineuron.in",
|
||||
"phone": "+91-99033-28741",
|
||||
"buyer_type": "family_office",
|
||||
"city": "Mumbai",
|
||||
"nationality": "Indian",
|
||||
"persona": ["portfolio_allocator", "low_visibility_buyer"],
|
||||
"budget": "20-30 Cr",
|
||||
"urgency": "critical",
|
||||
"status": "negotiation",
|
||||
"project": "Victoria Gardens Private Residences",
|
||||
"configuration": "Penthouse Duplex",
|
||||
"unit": "Private elevator stack",
|
||||
"budget_min": 200000000,
|
||||
"budget_max": 300000000,
|
||||
"stage": "negotiation",
|
||||
"value": 265000000,
|
||||
"probability": 78,
|
||||
"next_action": "Prepare founder-level commercial note before family office review.",
|
||||
"scores": (0.95, 0.91, 0.96),
|
||||
},
|
||||
{
|
||||
"key": "rohan-kapoor",
|
||||
"name": "Rohan Kapoor",
|
||||
"email": "rohan.kapoor.startup@demo.desineuron.in",
|
||||
"phone": "+91-98311-73654",
|
||||
"buyer_type": "founder_buyer",
|
||||
"city": "Bengaluru",
|
||||
"nationality": "Indian",
|
||||
"persona": ["founder_liquidity_event", "design_led_buyer"],
|
||||
"budget": "6-10 Cr",
|
||||
"urgency": "high",
|
||||
"status": "contacted",
|
||||
"project": "Salt Lake Atelier Homes",
|
||||
"configuration": "3.5BHK Garden Home",
|
||||
"unit": "Podium garden facing",
|
||||
"budget_min": 60000000,
|
||||
"budget_max": 100000000,
|
||||
"stage": "qualified",
|
||||
"value": 83500000,
|
||||
"probability": 54,
|
||||
"next_action": "Send design moodboard and schedule Dream Weaver room concept.",
|
||||
"scores": (0.76, 0.83, 0.81),
|
||||
},
|
||||
{
|
||||
"key": "saira-hussain",
|
||||
"name": "Saira Hussain",
|
||||
"email": "saira.hussain.doctor@demo.desineuron.in",
|
||||
"phone": "+91-97482-66019",
|
||||
"buyer_type": "end_user",
|
||||
"city": "Kolkata",
|
||||
"nationality": "Indian",
|
||||
"persona": ["quiet_luxury", "school_proximity"],
|
||||
"budget": "4-6 Cr",
|
||||
"urgency": "medium",
|
||||
"status": "site_visited",
|
||||
"project": "Ballygunge Meridian",
|
||||
"configuration": "3BHK",
|
||||
"unit": "South-east corner",
|
||||
"budget_min": 40000000,
|
||||
"budget_max": 60000000,
|
||||
"stage": "proposal",
|
||||
"value": 52000000,
|
||||
"probability": 66,
|
||||
"next_action": "Share school-route comparison and revised parking availability.",
|
||||
"scores": (0.74, 0.68, 0.62),
|
||||
},
|
||||
{
|
||||
"key": "vikram-jalan",
|
||||
"name": "Vikram Jalan",
|
||||
"email": "vikram.jalan.broker@demo.desineuron.in",
|
||||
"phone": "+91-98300-91274",
|
||||
"buyer_type": "broker_referral",
|
||||
"city": "Kolkata",
|
||||
"nationality": "Indian",
|
||||
"persona": ["broker_network", "bulk_referral_potential"],
|
||||
"budget": "15-25 Cr",
|
||||
"urgency": "high",
|
||||
"status": "booking_initiated",
|
||||
"project": "Atri Aqua Sky Residences",
|
||||
"configuration": "4BHK River Deck",
|
||||
"unit": "Two adjacent units",
|
||||
"budget_min": 150000000,
|
||||
"budget_max": 250000000,
|
||||
"stage": "booking",
|
||||
"value": 212000000,
|
||||
"probability": 84,
|
||||
"next_action": "Confirm booking amount routing and broker mandate documentation.",
|
||||
"scores": (0.88, 0.77, 0.89),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
PROJECTS = {
|
||||
"Atri Aqua Sky Residences": {
|
||||
"developer": "Atri Group",
|
||||
"micro_market": "Batanagar Riverside",
|
||||
"address": "Maheshtala Riverside Corridor, Kolkata",
|
||||
"property_type": "apartment",
|
||||
"location": {"city": "Kolkata", "district": "Maheshtala", "lat": 22.4981, "lng": 88.2291},
|
||||
"price_bands": [
|
||||
{"unitType": "3BHK", "minINR": 72000000, "maxINR": 98000000},
|
||||
{"unitType": "4BHK Sky Villa", "minINR": 130000000, "maxINR": 190000000},
|
||||
],
|
||||
"unit_mix": [{"bedrooms": 3, "count": 42, "sizeSqft": 2450}, {"bedrooms": 4, "count": 18, "sizeSqft": 3800}],
|
||||
},
|
||||
"Alipore Azure Residences": {
|
||||
"developer": "Meridian Urban Estates",
|
||||
"micro_market": "Alipore",
|
||||
"address": "Judges Court Road, Alipore, Kolkata",
|
||||
"property_type": "apartment",
|
||||
"location": {"city": "Kolkata", "district": "Alipore", "lat": 22.5288, "lng": 88.3309},
|
||||
"price_bands": [{"unitType": "3BHK Signature", "minINR": 85000000, "maxINR": 125000000}],
|
||||
"unit_mix": [{"bedrooms": 3, "count": 36, "sizeSqft": 2850}, {"bedrooms": 4, "count": 16, "sizeSqft": 4100}],
|
||||
},
|
||||
"Victoria Gardens Private Residences": {
|
||||
"developer": "Heritage Habitat",
|
||||
"micro_market": "Maidan",
|
||||
"address": "Queen's Way Precinct, Kolkata",
|
||||
"property_type": "penthouse",
|
||||
"location": {"city": "Kolkata", "district": "Maidan", "lat": 22.5448, "lng": 88.3426},
|
||||
"price_bands": [{"unitType": "Penthouse Duplex", "minINR": 220000000, "maxINR": 320000000}],
|
||||
"unit_mix": [{"bedrooms": 5, "count": 8, "sizeSqft": 6200}],
|
||||
},
|
||||
"Salt Lake Atelier Homes": {
|
||||
"developer": "Studio Habitat",
|
||||
"micro_market": "Salt Lake Sector V",
|
||||
"address": "EM Bypass Connector, Salt Lake, Kolkata",
|
||||
"property_type": "apartment",
|
||||
"location": {"city": "Kolkata", "district": "Salt Lake", "lat": 22.5797, "lng": 88.4353},
|
||||
"price_bands": [{"unitType": "3.5BHK Garden Home", "minINR": 68000000, "maxINR": 98000000}],
|
||||
"unit_mix": [{"bedrooms": 3, "count": 28, "sizeSqft": 2350}],
|
||||
},
|
||||
"Ballygunge Meridian": {
|
||||
"developer": "Eastern Crest Realty",
|
||||
"micro_market": "Ballygunge",
|
||||
"address": "Ballygunge Circular Road, Kolkata",
|
||||
"property_type": "apartment",
|
||||
"location": {"city": "Kolkata", "district": "Ballygunge", "lat": 22.5276, "lng": 88.3651},
|
||||
"price_bands": [{"unitType": "3BHK", "minINR": 42000000, "maxINR": 62000000}],
|
||||
"unit_mix": [{"bedrooms": 3, "count": 54, "sizeSqft": 1780}],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def seed(conn: asyncpg.Connection, tenant_id: str, operator_user_id: str | None, dry_run: bool = False) -> dict[str, int]:
|
||||
now = datetime.now(timezone.utc)
|
||||
owner_user_ref = operator_user_id or f"{SEED_SOURCE}:operator"
|
||||
counts = {
|
||||
"people": 0,
|
||||
"leads": 0,
|
||||
"projects": 0,
|
||||
"properties": 0,
|
||||
"interests": 0,
|
||||
"opportunities": 0,
|
||||
"scores": 0,
|
||||
"interactions": 0,
|
||||
"edge_events": 0,
|
||||
"reminders": 0,
|
||||
"calendar_events": 0,
|
||||
"import_batches": 0,
|
||||
"import_proposals": 0,
|
||||
}
|
||||
if dry_run:
|
||||
return counts
|
||||
|
||||
project_ids: dict[str, str] = {}
|
||||
for name, project in PROJECTS.items():
|
||||
project_id = _stable_uuid(tenant_id, f"project:{name}")
|
||||
project_ids[name] = project_id
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO inventory_projects (
|
||||
project_id, project_name, developer_name, city, micro_market, address,
|
||||
total_units, project_status, location_json, amenities_json, metadata_json
|
||||
) VALUES (
|
||||
$1::uuid, $2, $3, 'Kolkata', $4, $5, $6, 'active',
|
||||
$7::jsonb, $8::jsonb, $9::jsonb
|
||||
)
|
||||
ON CONFLICT (project_name) DO UPDATE SET
|
||||
developer_name = EXCLUDED.developer_name,
|
||||
micro_market = EXCLUDED.micro_market,
|
||||
address = EXCLUDED.address,
|
||||
total_units = EXCLUDED.total_units,
|
||||
project_status = EXCLUDED.project_status,
|
||||
location_json = EXCLUDED.location_json,
|
||||
amenities_json = EXCLUDED.amenities_json,
|
||||
metadata_json = inventory_projects.metadata_json || EXCLUDED.metadata_json,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
project_id,
|
||||
name,
|
||||
project["developer"],
|
||||
project["micro_market"],
|
||||
project["address"],
|
||||
sum(item["count"] for item in project["unit_mix"]),
|
||||
_json(project["location"]),
|
||||
_json(["concierge", "private club", "fitness studio", "visitor lounge", "ev charging"]),
|
||||
_json({"seed_source": SEED_SOURCE, "ipad_demo": True}),
|
||||
)
|
||||
counts["projects"] += 1
|
||||
|
||||
property_id = _stable_uuid(tenant_id, f"inventory-property:{name}")
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO inventory_properties (
|
||||
property_id, tenant_id, source_id, project_name, developer_name,
|
||||
location, property_type, price_bands, unit_mix, amenities,
|
||||
status, validation_state, ingested_at, created_at, updated_at
|
||||
) VALUES (
|
||||
$1::uuid, $2, $3, $4, $5, $6::jsonb, $7, $8::jsonb, $9::jsonb,
|
||||
$10, 'active', $11::jsonb, NOW(), NOW(), NOW()
|
||||
)
|
||||
ON CONFLICT (property_id) DO UPDATE SET
|
||||
project_name = EXCLUDED.project_name,
|
||||
developer_name = EXCLUDED.developer_name,
|
||||
location = EXCLUDED.location,
|
||||
property_type = EXCLUDED.property_type,
|
||||
price_bands = EXCLUDED.price_bands,
|
||||
unit_mix = EXCLUDED.unit_mix,
|
||||
amenities = EXCLUDED.amenities,
|
||||
status = 'active',
|
||||
validation_state = EXCLUDED.validation_state,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
property_id,
|
||||
tenant_id,
|
||||
f"{SEED_SOURCE}:{name}",
|
||||
name,
|
||||
project["developer"],
|
||||
_json(project["location"]),
|
||||
project["property_type"],
|
||||
_json(project["price_bands"]),
|
||||
_json(project["unit_mix"]),
|
||||
["concierge", "clubhouse", "security", "ev charging", "landscaped deck"],
|
||||
_json({"seed_source": SEED_SOURCE, "validated_for_ipad_demo": True}),
|
||||
)
|
||||
counts["properties"] += 1
|
||||
|
||||
for index, client in enumerate(DEMO_CLIENTS):
|
||||
person_id = _stable_uuid(tenant_id, f"person:{client['key']}")
|
||||
lead_id = _stable_uuid(tenant_id, f"lead:{client['key']}")
|
||||
opportunity_id = _stable_uuid(tenant_id, f"opportunity:{client['key']}")
|
||||
project_id = project_ids[client["project"]]
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO crm_people (
|
||||
person_id, tenant_id, full_name, primary_email, primary_phone, city,
|
||||
nationality, buyer_type, persona_labels, source_confidence,
|
||||
metadata_json, created_at, updated_at
|
||||
) VALUES (
|
||||
$1::uuid, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, 0.98,
|
||||
$10::jsonb, NOW() - ($11::int * INTERVAL '2 days'), NOW()
|
||||
)
|
||||
ON CONFLICT (person_id) DO UPDATE SET
|
||||
full_name = EXCLUDED.full_name,
|
||||
primary_email = EXCLUDED.primary_email,
|
||||
primary_phone = EXCLUDED.primary_phone,
|
||||
city = EXCLUDED.city,
|
||||
nationality = EXCLUDED.nationality,
|
||||
buyer_type = EXCLUDED.buyer_type,
|
||||
persona_labels = EXCLUDED.persona_labels,
|
||||
source_confidence = EXCLUDED.source_confidence,
|
||||
metadata_json = crm_people.metadata_json || EXCLUDED.metadata_json,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
person_id,
|
||||
tenant_id,
|
||||
client["name"],
|
||||
client["email"],
|
||||
client["phone"],
|
||||
client["city"],
|
||||
client["nationality"],
|
||||
client["buyer_type"],
|
||||
_json(client["persona"]),
|
||||
_json({"seed_source": SEED_SOURCE, "investor_demo": True, "source_note": "iPad production readiness seed"}),
|
||||
index,
|
||||
)
|
||||
counts["people"] += 1
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO crm_leads (
|
||||
lead_id, tenant_id, person_id, source_system, status, budget_band,
|
||||
urgency, financing_posture, timeline_to_decision, objections,
|
||||
motivations, assigned_user_id, metadata_json, created_at, updated_at
|
||||
) VALUES (
|
||||
$1::uuid, $2, $3::uuid, 'ipad_investor_demo',
|
||||
$4::crm_lead_status, $5, $6, $7, $8, $9::jsonb, $10::jsonb,
|
||||
$11::uuid, $12::jsonb, NOW() - ($13::int * INTERVAL '2 days'), NOW()
|
||||
)
|
||||
ON CONFLICT (lead_id) DO UPDATE SET
|
||||
status = EXCLUDED.status,
|
||||
budget_band = EXCLUDED.budget_band,
|
||||
urgency = EXCLUDED.urgency,
|
||||
financing_posture = EXCLUDED.financing_posture,
|
||||
timeline_to_decision = EXCLUDED.timeline_to_decision,
|
||||
objections = EXCLUDED.objections,
|
||||
motivations = EXCLUDED.motivations,
|
||||
assigned_user_id = EXCLUDED.assigned_user_id,
|
||||
metadata_json = crm_leads.metadata_json || EXCLUDED.metadata_json,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
lead_id,
|
||||
tenant_id,
|
||||
person_id,
|
||||
client["status"],
|
||||
client["budget"],
|
||||
client["urgency"],
|
||||
"cash_and_structured_payment" if client["budget_min"] >= 100000000 else "bank_loan_preapproved",
|
||||
"30_days" if client["urgency"] in {"high", "critical"} else "60_to_90_days",
|
||||
_json(["needs_family_alignment"] if client["urgency"] != "critical" else ["price_protection", "privacy"]),
|
||||
_json(["upgrade_primary_home", "wealth_preservation", "status_address"]),
|
||||
operator_user_id,
|
||||
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
|
||||
index,
|
||||
)
|
||||
counts["leads"] += 1
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO crm_property_interests (
|
||||
interest_id, tenant_id, person_id, lead_id, project_id, project_name,
|
||||
unit_preference, configuration, budget_min, budget_max, priority, notes, created_at
|
||||
) VALUES (
|
||||
$1::uuid, $2, $3::uuid, $4::uuid, $5::uuid, $6, $7, $8, $9, $10, 1, $11, NOW()
|
||||
)
|
||||
ON CONFLICT (interest_id) DO UPDATE SET
|
||||
project_name = EXCLUDED.project_name,
|
||||
unit_preference = EXCLUDED.unit_preference,
|
||||
configuration = EXCLUDED.configuration,
|
||||
budget_min = EXCLUDED.budget_min,
|
||||
budget_max = EXCLUDED.budget_max,
|
||||
notes = EXCLUDED.notes
|
||||
""",
|
||||
_stable_uuid(tenant_id, f"interest:{client['key']}"),
|
||||
tenant_id,
|
||||
person_id,
|
||||
lead_id,
|
||||
project_id,
|
||||
client["project"],
|
||||
client["unit"],
|
||||
client["configuration"],
|
||||
client["budget_min"],
|
||||
client["budget_max"],
|
||||
f"Seeded for iPad investor demo by {SEED_SOURCE}.",
|
||||
)
|
||||
counts["interests"] += 1
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO crm_opportunities (
|
||||
opportunity_id, tenant_id, lead_id, project_id, stage, value,
|
||||
probability, expected_close_date, next_action, notes, metadata_json,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
$1::uuid, $2, $3::uuid, $4::uuid, $5::crm_opportunity_stage, $6,
|
||||
$7, $8::date, $9, $10, $11::jsonb, NOW() - ($12::int * INTERVAL '1 day'), NOW()
|
||||
)
|
||||
ON CONFLICT (opportunity_id) DO UPDATE SET
|
||||
project_id = EXCLUDED.project_id,
|
||||
stage = EXCLUDED.stage,
|
||||
value = EXCLUDED.value,
|
||||
probability = EXCLUDED.probability,
|
||||
expected_close_date = EXCLUDED.expected_close_date,
|
||||
next_action = EXCLUDED.next_action,
|
||||
notes = EXCLUDED.notes,
|
||||
metadata_json = crm_opportunities.metadata_json || EXCLUDED.metadata_json,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
opportunity_id,
|
||||
tenant_id,
|
||||
lead_id,
|
||||
project_id,
|
||||
client["stage"],
|
||||
client["value"],
|
||||
client["probability"],
|
||||
(now + timedelta(days=21 + index * 4)).date().isoformat(),
|
||||
client["next_action"],
|
||||
"Investor-demo opportunity with realistic project, value, and next-step context.",
|
||||
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
|
||||
index,
|
||||
)
|
||||
counts["opportunities"] += 1
|
||||
|
||||
for score_type, value in zip(("intent_score", "engagement_score", "urgency_score"), client["scores"]):
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO intel_qd_scores (
|
||||
qd_id, tenant_id, person_id, score_type, current_value,
|
||||
computed_at, evidence_refs_json, reasoning, metadata_json
|
||||
) VALUES (
|
||||
$1::uuid, $2, $3::uuid, $4, $5, NOW(), $6::jsonb, $7, $8::jsonb
|
||||
)
|
||||
ON CONFLICT (person_id, score_type) DO UPDATE SET
|
||||
tenant_id = EXCLUDED.tenant_id,
|
||||
current_value = EXCLUDED.current_value,
|
||||
computed_at = NOW(),
|
||||
evidence_refs_json = EXCLUDED.evidence_refs_json,
|
||||
reasoning = EXCLUDED.reasoning,
|
||||
metadata_json = intel_qd_scores.metadata_json || EXCLUDED.metadata_json
|
||||
""",
|
||||
_stable_uuid(tenant_id, f"qd:{client['key']}:{score_type}"),
|
||||
tenant_id,
|
||||
person_id,
|
||||
score_type,
|
||||
value,
|
||||
_json([f"seed:{client['key']}:interaction"]),
|
||||
f"{client['name']} shows {score_type.replace('_', ' ')} from recent budget, project, and follow-up signals.",
|
||||
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
|
||||
)
|
||||
counts["scores"] += 1
|
||||
|
||||
interaction_templates = [
|
||||
("whatsapp", "message", now - timedelta(hours=6 + index), "Confirmed budget band and asked for a project comparison deck."),
|
||||
("phone", "call", now - timedelta(days=1, hours=index), "Discussed decision timeline, family alignment, and next visit window."),
|
||||
("site_visit", "visit", now - timedelta(days=3 + index), f"Reviewed {client['project']} and shortlisted {client['configuration']}."),
|
||||
]
|
||||
for event_index, (channel, interaction_type, happened_at, summary) in enumerate(interaction_templates):
|
||||
interaction_id = _stable_uuid(tenant_id, f"interaction:{client['key']}:{event_index}")
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO intel_interactions (
|
||||
interaction_id, tenant_id, person_id, lead_id, channel, interaction_type,
|
||||
happened_at, summary, source_ref, metadata_json, created_at
|
||||
) VALUES (
|
||||
$1::uuid, $2, $3::uuid, $4::uuid, $5::intel_channel, $6,
|
||||
$7, $8, $9, $10::jsonb, NOW()
|
||||
)
|
||||
ON CONFLICT (interaction_id) DO UPDATE SET
|
||||
happened_at = EXCLUDED.happened_at,
|
||||
summary = EXCLUDED.summary,
|
||||
metadata_json = intel_interactions.metadata_json || EXCLUDED.metadata_json
|
||||
""",
|
||||
interaction_id,
|
||||
tenant_id,
|
||||
person_id,
|
||||
lead_id,
|
||||
channel,
|
||||
interaction_type,
|
||||
happened_at,
|
||||
summary,
|
||||
f"{SEED_SOURCE}:{client['key']}:{event_index}",
|
||||
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
|
||||
)
|
||||
counts["interactions"] += 1
|
||||
|
||||
edge_event_id = _stable_uuid(tenant_id, f"edge-event:{client['key']}")
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO edge_communication_events (
|
||||
event_id, tenant_id, lead_id, channel, direction, provider,
|
||||
capture_mode, consent_state, timestamp, duration_seconds,
|
||||
summary, raw_reference, provider_metadata, created_at, updated_at
|
||||
) VALUES (
|
||||
$1::uuid, $2, $3, 'whatsapp_message', 'inbound', 'operator_seed',
|
||||
'operator_note', 'granted', $4, NULL, $5, $6, $7::jsonb, NOW(), NOW()
|
||||
)
|
||||
ON CONFLICT (event_id) DO UPDATE SET
|
||||
timestamp = EXCLUDED.timestamp,
|
||||
summary = EXCLUDED.summary,
|
||||
provider_metadata = EXCLUDED.provider_metadata,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
edge_event_id,
|
||||
tenant_id,
|
||||
lead_id,
|
||||
now - timedelta(minutes=25 + index * 12),
|
||||
f"{client['name']} asked the operator to proceed with the next step: {client['next_action']}",
|
||||
f"{SEED_SOURCE}:{client['key']}",
|
||||
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
|
||||
)
|
||||
counts["edge_events"] += 1
|
||||
|
||||
reminder_id = _stable_uuid(tenant_id, f"reminder:{client['key']}")
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO intel_reminders (
|
||||
reminder_id, tenant_id, person_id, lead_id, opportunity_id, reminder_type,
|
||||
title, notes, due_at, status, assigned_to, created_by_type, priority,
|
||||
metadata_json, created_at
|
||||
) VALUES (
|
||||
$1::uuid, $2, $3::uuid, $4::uuid, $5::uuid, 'follow_up',
|
||||
$6, $7, $8, 'pending', $9::uuid, 'human', $10, $11::jsonb, NOW()
|
||||
)
|
||||
ON CONFLICT (reminder_id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
notes = EXCLUDED.notes,
|
||||
due_at = EXCLUDED.due_at,
|
||||
status = 'pending',
|
||||
assigned_to = EXCLUDED.assigned_to,
|
||||
priority = EXCLUDED.priority,
|
||||
metadata_json = intel_reminders.metadata_json || EXCLUDED.metadata_json
|
||||
""",
|
||||
reminder_id,
|
||||
tenant_id,
|
||||
person_id,
|
||||
lead_id,
|
||||
opportunity_id,
|
||||
f"Follow up with {client['name']} on {client['project']}",
|
||||
client["next_action"],
|
||||
now + timedelta(hours=3 + index * 4),
|
||||
operator_user_id,
|
||||
"urgent" if client["urgency"] == "critical" else ("high" if client["urgency"] == "high" else "normal"),
|
||||
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
|
||||
)
|
||||
counts["reminders"] += 1
|
||||
|
||||
calendar_id = _stable_uuid(tenant_id, f"calendar:{client['key']}")
|
||||
start_at = now + timedelta(days=1 + (index % 3), hours=2 + index)
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO user_calendar_events (
|
||||
calendar_event_id, tenant_id, owner_user_id, lead_id, source_event_id,
|
||||
title, description, start_at, end_at, all_day, status,
|
||||
reminder_minutes, created_by, is_nemoclaw_confirmed, location,
|
||||
metadata, created_at, updated_at
|
||||
) VALUES (
|
||||
$1::uuid, $2, $3, $4, $5::uuid, $6, $7, $8, $9, FALSE, 'confirmed',
|
||||
ARRAY[15, 60], 'user', TRUE, $10, $11::jsonb, NOW(), NOW()
|
||||
)
|
||||
ON CONFLICT (calendar_event_id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
description = EXCLUDED.description,
|
||||
start_at = EXCLUDED.start_at,
|
||||
end_at = EXCLUDED.end_at,
|
||||
status = 'confirmed',
|
||||
reminder_minutes = EXCLUDED.reminder_minutes,
|
||||
location = EXCLUDED.location,
|
||||
metadata = EXCLUDED.metadata,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
calendar_id,
|
||||
tenant_id,
|
||||
owner_user_ref,
|
||||
lead_id,
|
||||
edge_event_id,
|
||||
f"{client['name']} - {client['configuration']} review",
|
||||
client["next_action"],
|
||||
start_at,
|
||||
start_at + timedelta(minutes=45),
|
||||
client["project"],
|
||||
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
|
||||
)
|
||||
counts["calendar_events"] += 1
|
||||
|
||||
batch_id = _stable_uuid(tenant_id, "workflow-import-batch:investor-demo")
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO workflow_import_batches (
|
||||
batch_id, tenant_id, source_system, uploaded_filename, mime_type, storage_ref,
|
||||
row_count, mapped_count, unresolved_count, canonical_count, uploaded_by,
|
||||
lifecycle, mapping_manifest, errors_json, metadata_json, created_at, updated_at
|
||||
) VALUES (
|
||||
$1::uuid, $2, 'sales_ops_csv', 'investor_demo_priority_clients.csv', 'text/csv',
|
||||
$3, 6, 5, 1, 0, $4::uuid, 'proposed',
|
||||
$5::jsonb, '[]'::jsonb, $6::jsonb, NOW() - INTERVAL '2 hours', NOW()
|
||||
)
|
||||
ON CONFLICT (batch_id) DO UPDATE SET
|
||||
row_count = EXCLUDED.row_count,
|
||||
mapped_count = EXCLUDED.mapped_count,
|
||||
unresolved_count = EXCLUDED.unresolved_count,
|
||||
lifecycle = EXCLUDED.lifecycle,
|
||||
mapping_manifest = EXCLUDED.mapping_manifest,
|
||||
metadata_json = workflow_import_batches.metadata_json || EXCLUDED.metadata_json,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
batch_id,
|
||||
tenant_id,
|
||||
f"s3://velocity-demo/{SEED_SOURCE}/investor_demo_priority_clients.csv",
|
||||
operator_user_id,
|
||||
_json({"mapped": {"Name": "full_name", "Phone": "primary_phone", "Budget": "budget_band"}}),
|
||||
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
|
||||
)
|
||||
counts["import_batches"] += 1
|
||||
|
||||
for row_number, client in enumerate(DEMO_CLIENTS[:3], start=1):
|
||||
action_id = _stable_uuid(tenant_id, f"workflow-import-proposal:{client['key']}")
|
||||
payload = {
|
||||
"batch_id": batch_id,
|
||||
"row_number": row_number,
|
||||
"canonical_payload": {
|
||||
"full_name": client["name"],
|
||||
"primary_phone": client["phone"],
|
||||
"buyer_type": client["buyer_type"],
|
||||
"budget_band": client["budget"],
|
||||
"project_name": client["project"],
|
||||
},
|
||||
"raw_row": {
|
||||
"Name": client["name"],
|
||||
"Phone": client["phone"],
|
||||
"Budget": client["budget"],
|
||||
"Project": client["project"],
|
||||
},
|
||||
"unresolved_fields": [] if row_number < 3 else ["preferred_visit_time"],
|
||||
"missing_required": [],
|
||||
"confidence": 0.92 - (row_number * 0.04),
|
||||
"review_required": True,
|
||||
}
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO workflow_actions (
|
||||
action_id, tenant_id, action_type, target_domain, target_entity_ref,
|
||||
proposal_payload, reasoning_summary, evidence_refs, confidence,
|
||||
status, approval_required, created_by_agent, created_at, updated_at
|
||||
) VALUES (
|
||||
$1::uuid, $2, 'import_proposal', 'crm', $3,
|
||||
$4::jsonb, $5, $6::jsonb, $7, 'pending', TRUE,
|
||||
'velocity_ipad_seed', NOW() - INTERVAL '90 minutes', NOW()
|
||||
)
|
||||
ON CONFLICT (action_id) DO UPDATE SET
|
||||
proposal_payload = EXCLUDED.proposal_payload,
|
||||
reasoning_summary = EXCLUDED.reasoning_summary,
|
||||
evidence_refs = EXCLUDED.evidence_refs,
|
||||
confidence = EXCLUDED.confidence,
|
||||
status = 'pending',
|
||||
approval_required = TRUE,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
action_id,
|
||||
tenant_id,
|
||||
client["email"],
|
||||
_json(payload),
|
||||
f"Mapped {client['name']} from realistic investor-demo CRM import.",
|
||||
_json([f"batch:{batch_id}", f"seed_source:{SEED_SOURCE}"]),
|
||||
payload["confidence"],
|
||||
)
|
||||
counts["import_proposals"] += 1
|
||||
|
||||
return counts
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--operator-email", default=os.getenv("VELOCITY_DEMO_OPERATOR_EMAIL", DEFAULT_OPERATOR_EMAIL))
|
||||
parser.add_argument("--tenant-id", default=os.getenv("VELOCITY_DEMO_TENANT_ID"))
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
_load_env()
|
||||
if asyncpg is None:
|
||||
raise RuntimeError(
|
||||
"asyncpg is not installed. Install backend requirements first: "
|
||||
"python3 -m pip install -r backend/requirements.txt"
|
||||
)
|
||||
if args.dry_run:
|
||||
try:
|
||||
db_kwargs = _db_kwargs()
|
||||
except RuntimeError as exc:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"status": "dry_run_without_db",
|
||||
"seed_source": SEED_SOURCE,
|
||||
"operator_email": args.operator_email,
|
||||
"database_note": str(exc),
|
||||
"expected_counts": _expected_counts(),
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return
|
||||
else:
|
||||
db_kwargs = _db_kwargs()
|
||||
conn = await asyncpg.connect(**db_kwargs)
|
||||
try:
|
||||
tenant_id, operator_user_id = await _resolve_operator(conn, args.operator_email, args.tenant_id)
|
||||
counts = _expected_counts() if args.dry_run else await seed(conn, tenant_id, operator_user_id)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"status": "dry_run" if args.dry_run else "seeded",
|
||||
"seed_source": SEED_SOURCE,
|
||||
"tenant_id": tenant_id,
|
||||
"operator_email": args.operator_email,
|
||||
"operator_user_id": operator_user_id,
|
||||
"counts": counts,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -11,12 +11,35 @@ As specified in Doc 07 (Client360Snapshot contract) and Doc 08 (Adapter Spec).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("velocity.client_graph.aggregation")
|
||||
|
||||
|
||||
def _json_string_list(value: Any) -> list[str]:
|
||||
"""Normalize canonical array fields that may arrive as jsonb, text[], or JSON text."""
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list | tuple):
|
||||
return [str(item) for item in value if item is not None]
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
return []
|
||||
try:
|
||||
parsed = json.loads(normalized)
|
||||
except json.JSONDecodeError:
|
||||
return [normalized]
|
||||
if isinstance(parsed, list):
|
||||
return [str(item) for item in parsed if item is not None]
|
||||
if parsed is None:
|
||||
return []
|
||||
return [str(parsed)]
|
||||
return [str(value)]
|
||||
|
||||
|
||||
def _serialize_person(row: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"person_id": str(row["person_id"]),
|
||||
@@ -24,7 +47,7 @@ def _serialize_person(row: Any) -> dict[str, Any]:
|
||||
"primary_email": row["primary_email"],
|
||||
"primary_phone": row["primary_phone"],
|
||||
"buyer_type": row["buyer_type"],
|
||||
"persona_labels": row["persona_labels"] or [],
|
||||
"persona_labels": _json_string_list(row["persona_labels"]),
|
||||
"source_confidence": float(row["source_confidence"] or 0.0),
|
||||
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
|
||||
}
|
||||
@@ -38,8 +61,8 @@ def _serialize_lead(row: Any) -> dict[str, Any]:
|
||||
"urgency": row["urgency"],
|
||||
"financing_posture": row["financing_posture"],
|
||||
"timeline_to_decision": row["timeline_to_decision"],
|
||||
"objections": row["objections"] or [],
|
||||
"motivations": row["motivations"] or [],
|
||||
"objections": _json_string_list(row["objections"]),
|
||||
"motivations": _json_string_list(row["motivations"]),
|
||||
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
|
||||
}
|
||||
|
||||
@@ -99,7 +122,7 @@ def _serialize_property_interest(row: Any) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
async def get_client_360(conn: Any, person_id: str) -> dict[str, Any] | None:
|
||||
async def get_client_360(conn: Any, tenant_id: str, person_id: str) -> dict[str, Any] | None:
|
||||
"""
|
||||
Aggregate a full Client360Snapshot for a given person_id.
|
||||
This is a read model — derived from canonical tables, never primary truth.
|
||||
@@ -111,8 +134,10 @@ async def get_client_360(conn: Any, person_id: str) -> dict[str, Any] | None:
|
||||
buyer_type, persona_labels, source_confidence, created_at
|
||||
FROM crm_people
|
||||
WHERE person_id = $1::uuid
|
||||
AND tenant_id = $2
|
||||
""",
|
||||
person_id,
|
||||
tenant_id,
|
||||
)
|
||||
if not person_row:
|
||||
return None
|
||||
@@ -126,9 +151,12 @@ async def get_client_360(conn: Any, person_id: str) -> dict[str, Any] | None:
|
||||
FROM crm_accounts ca
|
||||
INNER JOIN crm_leads cl ON cl.account_id = ca.account_id
|
||||
WHERE cl.person_id = $1::uuid
|
||||
AND cl.tenant_id = $2
|
||||
AND ca.tenant_id = $2
|
||||
LIMIT 5
|
||||
""",
|
||||
person_id,
|
||||
tenant_id,
|
||||
)
|
||||
account_links = [
|
||||
{
|
||||
@@ -147,10 +175,12 @@ async def get_client_360(conn: Any, person_id: str) -> dict[str, Any] | None:
|
||||
timeline_to_decision, objections, motivations, created_at
|
||||
FROM crm_leads
|
||||
WHERE person_id = $1::uuid
|
||||
AND tenant_id = $2
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
person_id,
|
||||
tenant_id,
|
||||
)
|
||||
lead = _serialize_lead(lead_row) if lead_row else None
|
||||
|
||||
@@ -162,10 +192,13 @@ async def get_client_360(conn: Any, person_id: str) -> dict[str, Any] | None:
|
||||
FROM crm_opportunities co
|
||||
INNER JOIN crm_leads cl ON cl.lead_id = co.lead_id
|
||||
WHERE cl.person_id = $1::uuid
|
||||
AND cl.tenant_id = $2
|
||||
AND co.tenant_id = $2
|
||||
ORDER BY co.updated_at DESC
|
||||
LIMIT 5
|
||||
""",
|
||||
person_id,
|
||||
tenant_id,
|
||||
)
|
||||
active_opportunities = [_serialize_opportunity(r) for r in opp_rows]
|
||||
|
||||
@@ -175,10 +208,12 @@ async def get_client_360(conn: Any, person_id: str) -> dict[str, Any] | None:
|
||||
SELECT interaction_id, channel, interaction_type, happened_at, summary
|
||||
FROM intel_interactions
|
||||
WHERE person_id = $1::uuid
|
||||
AND tenant_id = $2
|
||||
ORDER BY happened_at DESC
|
||||
LIMIT 10
|
||||
""",
|
||||
person_id,
|
||||
tenant_id,
|
||||
)
|
||||
recent_interactions = [_serialize_interaction(r) for r in interaction_rows]
|
||||
|
||||
@@ -189,10 +224,12 @@ async def get_client_360(conn: Any, person_id: str) -> dict[str, Any] | None:
|
||||
budget_min, budget_max, priority
|
||||
FROM crm_property_interests
|
||||
WHERE person_id = $1::uuid
|
||||
AND tenant_id = $2
|
||||
ORDER BY priority ASC, interest_id ASC
|
||||
LIMIT 10
|
||||
""",
|
||||
person_id,
|
||||
tenant_id,
|
||||
)
|
||||
property_interests = [_serialize_property_interest(r) for r in interest_rows]
|
||||
|
||||
@@ -202,11 +239,13 @@ async def get_client_360(conn: Any, person_id: str) -> dict[str, Any] | None:
|
||||
SELECT reminder_id, reminder_type, title, due_at, status, priority
|
||||
FROM intel_reminders
|
||||
WHERE person_id = $1::uuid
|
||||
AND tenant_id = $2
|
||||
AND status IN ('pending', 'snoozed')
|
||||
ORDER BY due_at ASC NULLS LAST
|
||||
LIMIT 10
|
||||
""",
|
||||
person_id,
|
||||
tenant_id,
|
||||
)
|
||||
tasks = [_serialize_reminder(r) for r in task_rows]
|
||||
|
||||
@@ -216,8 +255,10 @@ async def get_client_360(conn: Any, person_id: str) -> dict[str, Any] | None:
|
||||
SELECT score_type, current_value, computed_at, reasoning
|
||||
FROM intel_qd_scores
|
||||
WHERE person_id = $1::uuid
|
||||
AND tenant_id = $2
|
||||
""",
|
||||
person_id,
|
||||
tenant_id,
|
||||
)
|
||||
qd_overview = {r["score_type"]: _serialize_qd_score(r) for r in qd_rows}
|
||||
|
||||
@@ -262,6 +303,7 @@ async def get_client_360(conn: Any, person_id: str) -> dict[str, Any] | None:
|
||||
|
||||
async def get_contact_list(
|
||||
conn: Any,
|
||||
tenant_id: str,
|
||||
search: str | None = None,
|
||||
buyer_type: str | None = None,
|
||||
status: str | None = None,
|
||||
@@ -272,8 +314,8 @@ async def get_contact_list(
|
||||
Paginated contact list with lead status and QD summary.
|
||||
Implements the 'summary query' pattern from Doc 09.
|
||||
"""
|
||||
clauses: list[str] = ["1=1"]
|
||||
params: list[Any] = []
|
||||
clauses: list[str] = ["p.tenant_id = $1"]
|
||||
params: list[Any] = [tenant_id]
|
||||
|
||||
if search:
|
||||
params.append(f"%{search}%")
|
||||
@@ -310,14 +352,15 @@ async def get_contact_list(
|
||||
COALESCE(qs.intent_value, 0.0) AS intent_score,
|
||||
COALESCE(qs.engagement_value, qs.intent_value, 0.0) AS engagement_score,
|
||||
COALESCE(qs.urgency_value, 0.0) AS urgency_score,
|
||||
(SELECT COUNT(*) FROM intel_interactions ii WHERE ii.person_id = p.person_id) AS interaction_count,
|
||||
(SELECT MAX(happened_at) FROM intel_interactions ii WHERE ii.person_id = p.person_id) AS last_interaction_at,
|
||||
(SELECT COUNT(*) FROM intel_reminders ir WHERE ir.person_id = p.person_id AND ir.status = 'pending') AS pending_tasks
|
||||
(SELECT COUNT(*) FROM intel_interactions ii WHERE ii.person_id = p.person_id AND ii.tenant_id = p.tenant_id) AS interaction_count,
|
||||
(SELECT MAX(happened_at) FROM intel_interactions ii WHERE ii.person_id = p.person_id AND ii.tenant_id = p.tenant_id) AS last_interaction_at,
|
||||
(SELECT COUNT(*) FROM intel_reminders ir WHERE ir.person_id = p.person_id AND ir.tenant_id = p.tenant_id AND ir.status = 'pending') AS pending_tasks
|
||||
FROM crm_people p
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT lead_id, status, budget_band, urgency
|
||||
FROM crm_leads
|
||||
WHERE person_id = p.person_id
|
||||
AND tenant_id = p.tenant_id
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
) cl ON TRUE
|
||||
@@ -325,6 +368,7 @@ async def get_contact_list(
|
||||
SELECT project_name
|
||||
FROM crm_property_interests
|
||||
WHERE person_id = p.person_id
|
||||
AND tenant_id = p.tenant_id
|
||||
ORDER BY priority ASC, created_at DESC
|
||||
LIMIT 1
|
||||
) pi ON TRUE
|
||||
@@ -335,6 +379,7 @@ async def get_contact_list(
|
||||
MAX(CASE WHEN score_type = 'urgency_score' THEN current_value END) AS urgency_value
|
||||
FROM intel_qd_scores
|
||||
WHERE person_id = p.person_id
|
||||
AND tenant_id = p.tenant_id
|
||||
) qs ON TRUE
|
||||
{where}
|
||||
ORDER BY last_interaction_at DESC NULLS LAST, p.created_at DESC
|
||||
@@ -344,7 +389,7 @@ async def get_contact_list(
|
||||
count_query = f"""
|
||||
SELECT COUNT(*)
|
||||
FROM crm_people p
|
||||
LEFT JOIN crm_leads cl ON cl.person_id = p.person_id
|
||||
LEFT JOIN crm_leads cl ON cl.person_id = p.person_id AND cl.tenant_id = p.tenant_id
|
||||
{where}
|
||||
"""
|
||||
|
||||
|
||||
@@ -202,6 +202,7 @@ def create_import_batch_record(
|
||||
mapping_manifest: dict[str, Any],
|
||||
source_system: str = "csv_upload",
|
||||
uploaded_by_id: str | None = None,
|
||||
tenant_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Build the workflow_import_batches record payload.
|
||||
@@ -216,6 +217,7 @@ def create_import_batch_record(
|
||||
"mapped_count": mapping_manifest.get("mapped_count", 0),
|
||||
"unresolved_count": mapping_manifest.get("unmapped_count", 0),
|
||||
"uploaded_by": uploaded_by_id,
|
||||
"tenant_id": tenant_id,
|
||||
"lifecycle": "parsed",
|
||||
"mapping_manifest": mapping_manifest,
|
||||
"created_at": now,
|
||||
@@ -230,15 +232,16 @@ async def persist_import_batch(conn: Any, batch: dict[str, Any]) -> str:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO workflow_import_batches (
|
||||
batch_id, source_system, uploaded_filename, mime_type, row_count,
|
||||
batch_id, tenant_id, source_system, uploaded_filename, mime_type, row_count,
|
||||
mapped_count, unresolved_count, uploaded_by, lifecycle, mapping_manifest,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
$1::uuid, $2, $3, $4, $5, $6, $7,
|
||||
$8::uuid, $9::import_lifecycle, $10::jsonb, NOW(), NOW()
|
||||
$1::uuid, $2, $3, $4, $5, $6, $7, $8,
|
||||
$9::uuid, $10::import_lifecycle, $11::jsonb, NOW(), NOW()
|
||||
)
|
||||
""",
|
||||
batch["batch_id"],
|
||||
batch["tenant_id"],
|
||||
batch["source_system"],
|
||||
batch.get("uploaded_filename", "unknown.csv"),
|
||||
batch.get("mime_type", "text/csv"),
|
||||
@@ -253,7 +256,7 @@ async def persist_import_batch(conn: Any, batch: dict[str, Any]) -> str:
|
||||
|
||||
|
||||
async def persist_proposals_as_workflow_actions(
|
||||
conn: Any, proposals: list[dict[str, Any]]
|
||||
conn: Any, proposals: list[dict[str, Any]], tenant_id: str
|
||||
) -> int:
|
||||
"""
|
||||
Insert proposals into workflow_actions table for human review.
|
||||
@@ -264,15 +267,16 @@ async def persist_proposals_as_workflow_actions(
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO workflow_actions (
|
||||
action_id, action_type, target_domain, proposal_payload,
|
||||
action_id, tenant_id, action_type, target_domain, proposal_payload,
|
||||
reasoning_summary, confidence, status, approval_required,
|
||||
created_by_agent, created_at, updated_at
|
||||
) VALUES (
|
||||
$1::uuid, 'import_proposal', 'crm', $2::jsonb,
|
||||
$3, $4, 'pending'::wf_status, $5, 'ingest_service', NOW(), NOW()
|
||||
$1::uuid, $2, 'import_proposal', 'crm', $3::jsonb,
|
||||
$4, $5, 'pending'::wf_status, $6, 'ingest_service', NOW(), NOW()
|
||||
)
|
||||
""",
|
||||
p["proposal_id"],
|
||||
tenant_id,
|
||||
json.dumps(p),
|
||||
f"Import row {p['row_number']}: {p['canonical_payload'].get('full_name', 'unknown')}",
|
||||
p["confidence"],
|
||||
|
||||
212
backend/tests/test_auth_tenant_contract.py
Normal file
212
backend/tests/test_auth_tenant_contract.py
Normal file
@@ -0,0 +1,212 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
from jose import jwt
|
||||
|
||||
os.environ.setdefault("VELOCITY_JWT_SECRET", "test-secret")
|
||||
|
||||
from backend.auth.dependencies import UserPrincipal
|
||||
from backend.auth import service as auth_service
|
||||
|
||||
auth_service.verify_password = lambda plain, hashed: plain == hashed
|
||||
|
||||
|
||||
class AppState:
|
||||
def __init__(self, pool: Any) -> None:
|
||||
self.db_pool = pool
|
||||
self._auth_user_directory_schema_ready = False
|
||||
|
||||
|
||||
class AppStub:
|
||||
def __init__(self, pool: Any) -> None:
|
||||
self.state = AppState(pool)
|
||||
|
||||
|
||||
class RequestStub:
|
||||
def __init__(self, pool: Any) -> None:
|
||||
self.app = AppStub(pool)
|
||||
|
||||
|
||||
class FakeConn:
|
||||
def __init__(self) -> None:
|
||||
password_hash = "velocity-demo-password"
|
||||
self.users: dict[str, dict[str, Any]] = {
|
||||
"user-alpha": {
|
||||
"id": "00000000-0000-0000-0000-000000000001",
|
||||
"email": "alpha@example.com",
|
||||
"password_hash": password_hash,
|
||||
"role": "ADMIN",
|
||||
"tenant_id": "tenant_alpha",
|
||||
"full_name": "Alpha Operator",
|
||||
"avatar_url": "/assets/profile_avatars/alpha.png",
|
||||
"is_active": True,
|
||||
},
|
||||
"user-beta": {
|
||||
"id": "00000000-0000-0000-0000-000000000002",
|
||||
"email": "beta@example.com",
|
||||
"password_hash": password_hash,
|
||||
"role": "ADMIN",
|
||||
"tenant_id": "tenant_beta",
|
||||
"full_name": "Beta Operator",
|
||||
"avatar_url": "/assets/profile_avatars/beta.png",
|
||||
"is_active": True,
|
||||
},
|
||||
"user-legacy": {
|
||||
"id": "00000000-0000-0000-0000-000000000003",
|
||||
"email": "legacy@example.com",
|
||||
"password_hash": password_hash,
|
||||
"role": "SENIOR_BROKER",
|
||||
"tenant_id": "",
|
||||
"full_name": "Legacy Tenant User",
|
||||
"avatar_url": None,
|
||||
"is_active": True,
|
||||
},
|
||||
}
|
||||
self.schema_ready = False
|
||||
|
||||
async def execute(self, query: str, *args):
|
||||
normalized = " ".join(query.strip().split())
|
||||
if normalized.startswith("ALTER TABLE users_and_roles ADD COLUMN IF NOT EXISTS tenant_id TEXT"):
|
||||
for user in self.users.values():
|
||||
user.setdefault("tenant_id", "")
|
||||
return "ALTER TABLE"
|
||||
if normalized.startswith("UPDATE users_and_roles SET tenant_id = $1 WHERE tenant_id IS NULL OR tenant_id = ''"):
|
||||
for user in self.users.values():
|
||||
if not user.get("tenant_id"):
|
||||
user["tenant_id"] = args[0]
|
||||
return "UPDATE"
|
||||
if normalized.startswith("ALTER TABLE users_and_roles ALTER COLUMN tenant_id SET DEFAULT"):
|
||||
return "ALTER TABLE"
|
||||
if normalized.startswith("ALTER TABLE users_and_roles ALTER COLUMN tenant_id SET NOT NULL"):
|
||||
self.schema_ready = True
|
||||
return "ALTER TABLE"
|
||||
if normalized.startswith("CREATE INDEX IF NOT EXISTS idx_users_tenant_active ON users_and_roles (tenant_id, is_active)"):
|
||||
return "CREATE INDEX"
|
||||
if normalized.startswith("UPDATE users_and_roles SET avatar_url = $2 WHERE id = $1::uuid AND tenant_id = $3"):
|
||||
for user in self.users.values():
|
||||
if user["id"] == args[0] and user["tenant_id"] == args[2]:
|
||||
user["avatar_url"] = args[1]
|
||||
return "UPDATE 1"
|
||||
return "UPDATE 0"
|
||||
raise AssertionError(f"Unexpected execute query: {query}")
|
||||
|
||||
async def fetchrow(self, query: str, *args):
|
||||
normalized = " ".join(query.strip().split())
|
||||
if "FROM users_and_roles" not in normalized:
|
||||
raise AssertionError(f"Unexpected fetchrow query: {query}")
|
||||
if "password_hash" in normalized:
|
||||
email, tenant_fallback = args
|
||||
for user in self.users.values():
|
||||
if user["email"] == email and user["is_active"]:
|
||||
return {
|
||||
"id": user["id"],
|
||||
"role": user["role"],
|
||||
"password_hash": user["password_hash"],
|
||||
"tenant_id": user["tenant_id"] or tenant_fallback,
|
||||
}
|
||||
return None
|
||||
if "WHERE id = $1::uuid AND COALESCE(NULLIF(tenant_id, ''), $2) = $2" in normalized:
|
||||
user_id, tenant_id = args
|
||||
for user in self.users.values():
|
||||
resolved_tenant = user["tenant_id"] or tenant_id
|
||||
if user["id"] == user_id and resolved_tenant == tenant_id:
|
||||
return {
|
||||
"full_name": user["full_name"],
|
||||
"email": user["email"],
|
||||
"avatar_url": user["avatar_url"],
|
||||
"tenant_id": resolved_tenant,
|
||||
}
|
||||
return None
|
||||
raise AssertionError(f"Unexpected fetchrow query: {query}")
|
||||
|
||||
async def fetch(self, query: str, *args):
|
||||
normalized = " ".join(query.strip().split())
|
||||
if "FROM users_and_roles" not in normalized:
|
||||
raise AssertionError(f"Unexpected fetch query: {query}")
|
||||
tenant_fallback, tenant_id = args
|
||||
rows = []
|
||||
for user in self.users.values():
|
||||
resolved_tenant = user["tenant_id"] or tenant_fallback
|
||||
if user["is_active"] and resolved_tenant == tenant_id:
|
||||
rows.append(
|
||||
{
|
||||
"user_id": user["id"],
|
||||
"role": user["role"],
|
||||
"tenant_id": resolved_tenant,
|
||||
"full_name": user["full_name"],
|
||||
"email": user["email"],
|
||||
"avatar_url": user["avatar_url"],
|
||||
}
|
||||
)
|
||||
rows.sort(key=lambda row: (row["full_name"] or row["email"] or row["user_id"]))
|
||||
return rows
|
||||
|
||||
|
||||
class FakePool:
|
||||
def __init__(self) -> None:
|
||||
self.conn = FakeConn()
|
||||
|
||||
@asynccontextmanager
|
||||
async def acquire(self):
|
||||
yield self.conn
|
||||
|
||||
|
||||
def _build_request() -> tuple[RequestStub, FakePool]:
|
||||
pool = FakePool()
|
||||
return RequestStub(pool), pool
|
||||
|
||||
|
||||
def test_login_mints_token_with_user_tenant_id() -> None:
|
||||
request, pool = _build_request()
|
||||
|
||||
response = asyncio.run(
|
||||
auth_service.login_with_directory(
|
||||
app=request.app,
|
||||
email="alpha@example.com",
|
||||
password="velocity-demo-password",
|
||||
)
|
||||
)
|
||||
|
||||
payload = jwt.get_unverified_claims(response["access_token"])
|
||||
assert payload["tenant_id"] == "tenant_alpha"
|
||||
assert pool.conn.schema_ready is True
|
||||
|
||||
|
||||
def test_login_backfills_legacy_users_to_default_tenant_before_minting() -> None:
|
||||
request, pool = _build_request()
|
||||
|
||||
response = asyncio.run(
|
||||
auth_service.login_with_directory(
|
||||
app=request.app,
|
||||
email="legacy@example.com",
|
||||
password="velocity-demo-password",
|
||||
)
|
||||
)
|
||||
|
||||
payload = jwt.get_unverified_claims(response["access_token"])
|
||||
assert payload["tenant_id"] == "tenant_velocity"
|
||||
assert pool.conn.users["user-legacy"]["tenant_id"] == "tenant_velocity"
|
||||
|
||||
|
||||
def test_auth_me_returns_profile_for_authenticated_tenant() -> None:
|
||||
request, _pool = _build_request()
|
||||
user = UserPrincipal("00000000-0000-0000-0000-000000000001", "ADMIN", "tenant_alpha")
|
||||
|
||||
response = asyncio.run(auth_service.read_authenticated_user_profile(app=request.app, user=user))
|
||||
|
||||
assert response["tenant_id"] == "tenant_alpha"
|
||||
assert response["email"] == "alpha@example.com"
|
||||
|
||||
|
||||
def test_auth_users_are_scoped_to_authenticated_tenant() -> None:
|
||||
request, _pool = _build_request()
|
||||
user = UserPrincipal("00000000-0000-0000-0000-000000000001", "ADMIN", "tenant_alpha")
|
||||
|
||||
users = asyncio.run(auth_service.list_tenant_users(app=request.app, user=user))
|
||||
|
||||
assert [user["email"] for user in users] == ["alpha@example.com"]
|
||||
assert all(user["tenant_id"] == "tenant_alpha" for user in users)
|
||||
162
backend/tests/test_canonical_crm_auth.py
Normal file
162
backend/tests/test_canonical_crm_auth.py
Normal file
@@ -0,0 +1,162 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
os.environ.setdefault("VELOCITY_JWT_SECRET", "test-secret")
|
||||
|
||||
from backend.api import routes_crm_imports
|
||||
from backend.auth.dependencies import UserPrincipal, get_current_user
|
||||
|
||||
|
||||
class FakeConn:
|
||||
async def execute(self, query: str, *args):
|
||||
return "OK"
|
||||
|
||||
|
||||
class FakePool:
|
||||
def __init__(self) -> None:
|
||||
self.conn = FakeConn()
|
||||
|
||||
@asynccontextmanager
|
||||
async def acquire(self):
|
||||
yield self.conn
|
||||
|
||||
|
||||
def _build_app(*, authenticated: bool) -> TestClient:
|
||||
app = FastAPI()
|
||||
app.state.db_pool = FakePool()
|
||||
app.include_router(routes_crm_imports.router, prefix="/api")
|
||||
if authenticated:
|
||||
app.dependency_overrides[get_current_user] = lambda: UserPrincipal(
|
||||
"00000000-0000-0000-0000-000000000001",
|
||||
"ADMIN",
|
||||
"tenant_alpha",
|
||||
)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_canonical_crm_routes_require_authentication() -> None:
|
||||
client = _build_app(authenticated=False)
|
||||
|
||||
response = client.get("/api/crm/contacts")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == "Missing or malformed Authorization header."
|
||||
|
||||
|
||||
def test_canonical_crm_task_routes_require_authentication() -> None:
|
||||
client = _build_app(authenticated=False)
|
||||
|
||||
response = client.get("/api/crm/tasks")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == "Missing or malformed Authorization header."
|
||||
|
||||
|
||||
def test_canonical_crm_task_write_routes_require_authentication() -> None:
|
||||
client = _build_app(authenticated=False)
|
||||
|
||||
response = client.patch(
|
||||
"/api/crm/tasks/33333333-3333-3333-3333-333333333333",
|
||||
json={"status": "done"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == "Missing or malformed Authorization header."
|
||||
|
||||
|
||||
def test_canonical_crm_lead_stage_write_routes_require_authentication() -> None:
|
||||
client = _build_app(authenticated=False)
|
||||
|
||||
response = client.patch(
|
||||
"/api/crm/leads/22222222-2222-2222-2222-222222222222/stage",
|
||||
json={"status": "qualified"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == "Missing or malformed Authorization header."
|
||||
|
||||
|
||||
def test_canonical_crm_opportunity_write_routes_require_authentication() -> None:
|
||||
client = _build_app(authenticated=False)
|
||||
|
||||
response = client.patch(
|
||||
"/api/crm/opportunities/55555555-5555-5555-5555-555555555555",
|
||||
json={"stage": "negotiation"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == "Missing or malformed Authorization header."
|
||||
|
||||
|
||||
def test_canonical_crm_import_upload_requires_authentication() -> None:
|
||||
client = _build_app(authenticated=False)
|
||||
|
||||
response = client.post(
|
||||
"/api/crm/imports",
|
||||
params={"source_system": "csv_upload"},
|
||||
files={"file": ("contacts.csv", io.BytesIO(b"name,phone\nAmina,+9715000\n"), "text/csv")},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == "Missing or malformed Authorization header."
|
||||
|
||||
|
||||
def test_canonical_crm_contacts_can_be_read_when_authenticated(monkeypatch) -> None:
|
||||
client = _build_app(authenticated=True)
|
||||
|
||||
async def fake_get_contact_list(
|
||||
conn: Any,
|
||||
tenant_id: str,
|
||||
search: str | None = None,
|
||||
buyer_type: str | None = None,
|
||||
status: str | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
assert tenant_id == "tenant_alpha"
|
||||
assert search is None
|
||||
assert buyer_type is None
|
||||
assert status is None
|
||||
return {
|
||||
"contacts": [
|
||||
{
|
||||
"person_id": "11111111-1111-1111-1111-111111111111",
|
||||
"full_name": "Amina Rahman",
|
||||
"primary_email": "amina@example.com",
|
||||
"primary_phone": "+971500000001",
|
||||
"buyer_type": "high_intent",
|
||||
"lead_id": "22222222-2222-2222-2222-222222222222",
|
||||
"legacy_li_id": None,
|
||||
"lead_status": "qualified",
|
||||
"budget_band": "AED 12M",
|
||||
"urgency": "high",
|
||||
"primary_interest": "Marina Penthouse",
|
||||
"intent_score": 0.94,
|
||||
"engagement_score": 0.91,
|
||||
"urgency_score": 0.88,
|
||||
"interaction_count": 6,
|
||||
"last_interaction_at": "2026-04-22T10:00:00+00:00",
|
||||
"pending_tasks": 1,
|
||||
"created_at": "2026-04-21T10:00:00+00:00",
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(routes_crm_imports, "get_contact_list", fake_get_contact_list)
|
||||
|
||||
response = client.get("/api/crm/contacts")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()["data"]
|
||||
assert payload["total"] == 1
|
||||
assert payload["contacts"][0]["full_name"] == "Amina Rahman"
|
||||
517
backend/tests/test_canonical_crm_tenant_scoping.py
Normal file
517
backend/tests/test_canonical_crm_tenant_scoping.py
Normal file
@@ -0,0 +1,517 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
os.environ.setdefault("VELOCITY_JWT_SECRET", "test-secret")
|
||||
|
||||
from backend.api import routes_crm_imports
|
||||
from backend.auth.dependencies import UserPrincipal, get_current_user
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class FakeConn:
|
||||
def __init__(self) -> None:
|
||||
self.people: dict[str, dict[str, Any]] = {
|
||||
"11111111-1111-1111-1111-111111111111": {
|
||||
"person_id": "11111111-1111-1111-1111-111111111111",
|
||||
"tenant_id": "tenant_alpha",
|
||||
"full_name": "Amina Rahman",
|
||||
"primary_email": "amina@example.com",
|
||||
"primary_phone": "+971500000001",
|
||||
"secondary_phone": None,
|
||||
"buyer_type": "high_intent",
|
||||
"persona_labels": [],
|
||||
"source_confidence": 1.0,
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
}
|
||||
}
|
||||
self.leads: dict[str, dict[str, Any]] = {
|
||||
"22222222-2222-2222-2222-222222222222": {
|
||||
"lead_id": "22222222-2222-2222-2222-222222222222",
|
||||
"tenant_id": "tenant_alpha",
|
||||
"person_id": "11111111-1111-1111-1111-111111111111",
|
||||
"status": "new",
|
||||
"budget_band": "AED 12M",
|
||||
"urgency": "high",
|
||||
}
|
||||
}
|
||||
self.reminders: dict[str, dict[str, Any]] = {
|
||||
"33333333-3333-3333-3333-333333333333": {
|
||||
"reminder_id": "33333333-3333-3333-3333-333333333333",
|
||||
"tenant_id": "tenant_alpha",
|
||||
"person_id": "11111111-1111-1111-1111-111111111111",
|
||||
"lead_id": "22222222-2222-2222-2222-222222222222",
|
||||
"reminder_type": "follow_up",
|
||||
"title": "Call marina lead",
|
||||
"notes": "Confirm visit time",
|
||||
"status": "pending",
|
||||
"priority": "high",
|
||||
"due_at": _now(),
|
||||
},
|
||||
"44444444-4444-4444-4444-444444444444": {
|
||||
"reminder_id": "44444444-4444-4444-4444-444444444444",
|
||||
"tenant_id": "tenant_beta",
|
||||
"person_id": "99999999-9999-9999-9999-999999999999",
|
||||
"lead_id": None,
|
||||
"reminder_type": "follow_up",
|
||||
"title": "Cross-tenant task",
|
||||
"notes": "Should not leak",
|
||||
"status": "pending",
|
||||
"priority": "normal",
|
||||
"due_at": _now(),
|
||||
},
|
||||
}
|
||||
self.opportunities: dict[str, dict[str, Any]] = {
|
||||
"55555555-5555-5555-5555-555555555555": {
|
||||
"opportunity_id": "55555555-5555-5555-5555-555555555555",
|
||||
"tenant_id": "tenant_alpha",
|
||||
"lead_id": "22222222-2222-2222-2222-222222222222",
|
||||
"stage": "proposal",
|
||||
"value": 12000000.0,
|
||||
"probability": 60,
|
||||
"expected_close_date": None,
|
||||
"next_action": "Share proposal",
|
||||
"notes": "Initial terms shared",
|
||||
"project_name": "Marina Residences",
|
||||
},
|
||||
"66666666-6666-6666-6666-666666666666": {
|
||||
"opportunity_id": "66666666-6666-6666-6666-666666666666",
|
||||
"tenant_id": "tenant_beta",
|
||||
"lead_id": "99999999-9999-9999-9999-999999999999",
|
||||
"stage": "proposal",
|
||||
"value": 7000000.0,
|
||||
"probability": 50,
|
||||
"expected_close_date": None,
|
||||
"next_action": "Cross tenant",
|
||||
"notes": None,
|
||||
"project_name": None,
|
||||
},
|
||||
}
|
||||
self.stage_history: list[dict[str, Any]] = []
|
||||
|
||||
async def execute(self, query: str, *args):
|
||||
normalized = " ".join(query.strip().split())
|
||||
if normalized.startswith("ALTER TABLE ") or normalized.startswith("CREATE INDEX IF NOT EXISTS "):
|
||||
return "OK"
|
||||
if normalized.startswith("UPDATE ") and " SET tenant_id = $1 " in f" {normalized} ":
|
||||
return "UPDATE"
|
||||
if normalized.startswith("INSERT INTO crm_people"):
|
||||
self.people[args[0]] = {
|
||||
"person_id": args[0],
|
||||
"tenant_id": args[1],
|
||||
"full_name": args[2],
|
||||
"primary_email": args[3],
|
||||
"primary_phone": args[4],
|
||||
"secondary_phone": None,
|
||||
"buyer_type": args[5],
|
||||
"persona_labels": [],
|
||||
"source_confidence": 1.0,
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
}
|
||||
return "INSERT 1"
|
||||
if normalized.startswith("INSERT INTO crm_leads"):
|
||||
self.leads[args[0]] = {
|
||||
"lead_id": args[0],
|
||||
"tenant_id": args[1],
|
||||
"person_id": args[2],
|
||||
}
|
||||
return "INSERT 1"
|
||||
if normalized.startswith("INSERT INTO crm_property_interests"):
|
||||
return "INSERT 1"
|
||||
if normalized.startswith("INSERT INTO intel_reminders"):
|
||||
self.reminders[args[0]] = {
|
||||
"reminder_id": args[0],
|
||||
"tenant_id": args[1],
|
||||
"person_id": args[2],
|
||||
"lead_id": args[3],
|
||||
"reminder_type": args[4],
|
||||
"title": args[5],
|
||||
"notes": args[6],
|
||||
"due_at": args[7],
|
||||
"status": "pending",
|
||||
"priority": args[8],
|
||||
}
|
||||
return "INSERT 1"
|
||||
if normalized.startswith("INSERT INTO crm_stage_history"):
|
||||
self.stage_history.append(
|
||||
{
|
||||
"history_id": args[0],
|
||||
"lead_id": args[1],
|
||||
"from_status": args[2],
|
||||
"to_status": args[3],
|
||||
"changed_by": args[4],
|
||||
"notes": args[5],
|
||||
}
|
||||
)
|
||||
return "INSERT 1"
|
||||
raise AssertionError(f"Unexpected execute query: {query}")
|
||||
|
||||
async def fetchrow(self, query: str, *args):
|
||||
normalized = " ".join(query.strip().split())
|
||||
if "FROM crm_people" in normalized and "WHERE person_id = $1::uuid AND tenant_id = $2" in normalized:
|
||||
row = self.people.get(args[0])
|
||||
return dict(row) if row and row["tenant_id"] == args[1] else None
|
||||
if "FROM crm_leads" in normalized and "WHERE lead_id = $1::uuid AND person_id = $2::uuid AND tenant_id = $3" in normalized:
|
||||
row = self.leads.get(args[0])
|
||||
return dict(row) if row and row["person_id"] == args[1] and row["tenant_id"] == args[2] else None
|
||||
if "FROM intel_reminders" in normalized and "WHERE reminder_id = $1::uuid AND tenant_id = $2" in normalized:
|
||||
row = self.reminders.get(args[0])
|
||||
return dict(row) if row and row["tenant_id"] == args[1] else None
|
||||
if normalized.startswith("UPDATE intel_reminders ir SET status ="):
|
||||
row = self.reminders.get(args[0])
|
||||
if not row or row["tenant_id"] != args[1]:
|
||||
return None
|
||||
row["status"] = args[2]
|
||||
if args[3] is not None:
|
||||
row["due_at"] = args[3]
|
||||
if args[4] is not None:
|
||||
row["notes"] = args[4]
|
||||
person = self.people[row["person_id"]]
|
||||
return {
|
||||
"reminder_id": row["reminder_id"],
|
||||
"reminder_type": row["reminder_type"],
|
||||
"title": row["title"],
|
||||
"notes": row["notes"],
|
||||
"due_at": row["due_at"],
|
||||
"status": row["status"],
|
||||
"priority": row["priority"],
|
||||
"person_id": person["person_id"],
|
||||
"full_name": person["full_name"],
|
||||
"primary_phone": person["primary_phone"],
|
||||
}
|
||||
if "FROM crm_leads cl" in normalized and "WHERE cl.lead_id = $1::uuid AND cl.tenant_id = $2" in normalized:
|
||||
row = self.leads.get(args[0])
|
||||
if not row or row["tenant_id"] != args[1]:
|
||||
return None
|
||||
person = self.people[row["person_id"]]
|
||||
return {
|
||||
"lead_id": row["lead_id"],
|
||||
"person_id": row["person_id"],
|
||||
"status": row["status"],
|
||||
"budget_band": row["budget_band"],
|
||||
"urgency": row["urgency"],
|
||||
"full_name": person["full_name"],
|
||||
"primary_phone": person["primary_phone"],
|
||||
}
|
||||
if normalized.startswith("UPDATE crm_leads SET status = $3::crm_lead_status, updated_at = NOW()"):
|
||||
row = self.leads.get(args[0])
|
||||
if not row or row["tenant_id"] != args[1]:
|
||||
return None
|
||||
row["status"] = args[2]
|
||||
return {
|
||||
"lead_id": row["lead_id"],
|
||||
"person_id": row["person_id"],
|
||||
"status": row["status"],
|
||||
"budget_band": row["budget_band"],
|
||||
"urgency": row["urgency"],
|
||||
}
|
||||
if "FROM crm_opportunities co" in normalized and "WHERE co.opportunity_id = $1::uuid AND co.tenant_id = $2" in normalized:
|
||||
row = self.opportunities.get(args[0])
|
||||
if not row or row["tenant_id"] != args[1]:
|
||||
return None
|
||||
lead = self.leads[row["lead_id"]]
|
||||
person = self.people[lead["person_id"]]
|
||||
return {
|
||||
"opportunity_id": row["opportunity_id"],
|
||||
"stage": row["stage"],
|
||||
"value": row["value"],
|
||||
"probability": row["probability"],
|
||||
"expected_close_date": row["expected_close_date"],
|
||||
"next_action": row["next_action"],
|
||||
"notes": row["notes"],
|
||||
"person_id": person["person_id"],
|
||||
"full_name": person["full_name"],
|
||||
"primary_phone": person["primary_phone"],
|
||||
"project_name": row["project_name"],
|
||||
}
|
||||
if normalized.startswith("WITH updated AS ( UPDATE crm_opportunities co SET stage ="):
|
||||
row = self.opportunities.get(args[0])
|
||||
if not row or row["tenant_id"] != args[1]:
|
||||
return None
|
||||
if args[2] is not None:
|
||||
row["stage"] = args[2]
|
||||
if args[3]:
|
||||
row["value"] = args[4]
|
||||
if args[5]:
|
||||
row["probability"] = args[6]
|
||||
if args[7]:
|
||||
row["expected_close_date"] = args[8]
|
||||
if args[9]:
|
||||
row["next_action"] = args[10]
|
||||
if args[11]:
|
||||
row["notes"] = args[12]
|
||||
lead = self.leads[row["lead_id"]]
|
||||
person = self.people[lead["person_id"]]
|
||||
return {
|
||||
"opportunity_id": row["opportunity_id"],
|
||||
"stage": row["stage"],
|
||||
"value": row["value"],
|
||||
"probability": row["probability"],
|
||||
"expected_close_date": row["expected_close_date"],
|
||||
"next_action": row["next_action"],
|
||||
"notes": row["notes"],
|
||||
"person_id": person["person_id"],
|
||||
"full_name": person["full_name"],
|
||||
"primary_phone": person["primary_phone"],
|
||||
"project_name": row["project_name"],
|
||||
}
|
||||
raise AssertionError(f"Unexpected fetchrow query: {query}")
|
||||
|
||||
async def fetch(self, query: str, *args):
|
||||
normalized = " ".join(query.strip().split())
|
||||
if "FROM intel_reminders ir" in normalized:
|
||||
tenant_id = args[0]
|
||||
status_filter = args[1] if len(args) >= 3 else None
|
||||
rows = []
|
||||
for reminder in self.reminders.values():
|
||||
if reminder["tenant_id"] != tenant_id:
|
||||
continue
|
||||
if status_filter and reminder["status"] != status_filter:
|
||||
continue
|
||||
person = self.people.get(reminder["person_id"])
|
||||
if not person or person["tenant_id"] != tenant_id:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"reminder_id": reminder["reminder_id"],
|
||||
"reminder_type": reminder["reminder_type"],
|
||||
"title": reminder["title"],
|
||||
"notes": reminder["notes"],
|
||||
"due_at": reminder["due_at"],
|
||||
"status": reminder["status"],
|
||||
"priority": reminder["priority"],
|
||||
"person_id": person["person_id"],
|
||||
"full_name": person["full_name"],
|
||||
"primary_phone": person["primary_phone"],
|
||||
}
|
||||
)
|
||||
return rows
|
||||
raise AssertionError(f"Unexpected fetch query: {query}")
|
||||
|
||||
|
||||
class FakePool:
|
||||
def __init__(self) -> None:
|
||||
self.conn = FakeConn()
|
||||
|
||||
@asynccontextmanager
|
||||
async def acquire(self):
|
||||
yield self.conn
|
||||
|
||||
|
||||
def _build_shared_app(pool: FakePool, tenant_id: str) -> TestClient:
|
||||
app = FastAPI()
|
||||
app.state.db_pool = pool
|
||||
app.include_router(routes_crm_imports.router, prefix="/api")
|
||||
app.dependency_overrides[get_current_user] = lambda: UserPrincipal(
|
||||
"00000000-0000-0000-0000-000000000001",
|
||||
"ADMIN",
|
||||
tenant_id,
|
||||
)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_canonical_contact_list_receives_authenticated_tenant(monkeypatch) -> None:
|
||||
pool = FakePool()
|
||||
client = _build_shared_app(pool, "tenant_alpha")
|
||||
|
||||
async def fake_get_contact_list(
|
||||
conn: Any,
|
||||
tenant_id: str,
|
||||
search: str | None = None,
|
||||
buyer_type: str | None = None,
|
||||
status: str | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
assert tenant_id == "tenant_alpha"
|
||||
return {"contacts": [], "total": 0, "limit": limit, "offset": offset}
|
||||
|
||||
monkeypatch.setattr(routes_crm_imports, "get_contact_list", fake_get_contact_list)
|
||||
|
||||
response = client.get("/api/crm/contacts")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["data"]["total"] == 0
|
||||
|
||||
|
||||
def test_canonical_task_routes_are_scoped_to_authenticated_tenant() -> None:
|
||||
pool = FakePool()
|
||||
tenant_alpha = _build_shared_app(pool, "tenant_alpha")
|
||||
tenant_beta = _build_shared_app(pool, "tenant_beta")
|
||||
|
||||
alpha_response = tenant_alpha.get("/api/crm/tasks")
|
||||
beta_response = tenant_beta.get("/api/crm/tasks")
|
||||
|
||||
assert alpha_response.status_code == 200
|
||||
assert len(alpha_response.json()["data"]) == 1
|
||||
assert alpha_response.json()["data"][0]["title"] == "Call marina lead"
|
||||
|
||||
assert beta_response.status_code == 200
|
||||
assert beta_response.json()["data"] == []
|
||||
|
||||
|
||||
def test_create_contact_persists_authenticated_tenant_on_canonical_records() -> None:
|
||||
pool = FakePool()
|
||||
client = _build_shared_app(pool, "tenant_alpha")
|
||||
|
||||
response = client.post(
|
||||
"/api/crm/contacts",
|
||||
json={
|
||||
"full_name": "New Canonical Contact",
|
||||
"primary_phone": "+971500000010",
|
||||
"budget_band": "AED 8M",
|
||||
"project_name": "Skyline",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
person_id = response.json()["data"]["person_id"]
|
||||
created_person = pool.conn.people[person_id]
|
||||
assert created_person["tenant_id"] == "tenant_alpha"
|
||||
|
||||
assert any(lead["tenant_id"] == "tenant_alpha" and lead["person_id"] == person_id for lead in pool.conn.leads.values())
|
||||
|
||||
|
||||
def test_create_task_rejects_cross_tenant_person_reference() -> None:
|
||||
pool = FakePool()
|
||||
client = _build_shared_app(pool, "tenant_beta")
|
||||
|
||||
response = client.post(
|
||||
"/api/crm/tasks",
|
||||
json={
|
||||
"person_id": "11111111-1111-1111-1111-111111111111",
|
||||
"lead_id": "22222222-2222-2222-2222-222222222222",
|
||||
"title": "Cross tenant task",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Contact '11111111-1111-1111-1111-111111111111' not found."
|
||||
|
||||
|
||||
def test_update_task_marks_done_for_authenticated_tenant() -> None:
|
||||
pool = FakePool()
|
||||
client = _build_shared_app(pool, "tenant_alpha")
|
||||
|
||||
response = client.patch(
|
||||
"/api/crm/tasks/33333333-3333-3333-3333-333333333333",
|
||||
json={"status": "done"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["data"]["status"] == "done"
|
||||
assert payload["meta"]["previous_status"] == "pending"
|
||||
assert payload["meta"]["changed"] is True
|
||||
assert pool.conn.reminders["33333333-3333-3333-3333-333333333333"]["status"] == "done"
|
||||
|
||||
|
||||
def test_update_task_marks_confirmed_for_authenticated_tenant() -> None:
|
||||
pool = FakePool()
|
||||
client = _build_shared_app(pool, "tenant_alpha")
|
||||
|
||||
response = client.patch(
|
||||
"/api/crm/tasks/33333333-3333-3333-3333-333333333333",
|
||||
json={"status": "confirmed"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["data"]["status"] == "confirmed"
|
||||
assert payload["meta"]["previous_status"] == "pending"
|
||||
assert payload["meta"]["changed"] is True
|
||||
assert pool.conn.reminders["33333333-3333-3333-3333-333333333333"]["status"] == "confirmed"
|
||||
|
||||
|
||||
def test_update_task_rejects_cross_tenant_task_reference() -> None:
|
||||
pool = FakePool()
|
||||
client = _build_shared_app(pool, "tenant_beta")
|
||||
|
||||
response = client.patch(
|
||||
"/api/crm/tasks/33333333-3333-3333-3333-333333333333",
|
||||
json={"status": "done"},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Task '33333333-3333-3333-3333-333333333333' not found."
|
||||
|
||||
|
||||
def test_update_lead_stage_records_canonical_stage_history() -> None:
|
||||
pool = FakePool()
|
||||
client = _build_shared_app(pool, "tenant_alpha")
|
||||
|
||||
response = client.patch(
|
||||
"/api/crm/leads/22222222-2222-2222-2222-222222222222/stage",
|
||||
json={"status": "qualified", "notes": "Advanced from iPad Oracle pipeline."},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["data"]["status"] == "qualified"
|
||||
assert payload["meta"]["previous_status"] == "new"
|
||||
assert payload["meta"]["changed"] is True
|
||||
assert pool.conn.leads["22222222-2222-2222-2222-222222222222"]["status"] == "qualified"
|
||||
assert len(pool.conn.stage_history) == 1
|
||||
assert pool.conn.stage_history[0]["from_status"] == "new"
|
||||
assert pool.conn.stage_history[0]["to_status"] == "qualified"
|
||||
|
||||
|
||||
def test_update_lead_stage_rejects_cross_tenant_reference() -> None:
|
||||
pool = FakePool()
|
||||
client = _build_shared_app(pool, "tenant_beta")
|
||||
|
||||
response = client.patch(
|
||||
"/api/crm/leads/22222222-2222-2222-2222-222222222222/stage",
|
||||
json={"status": "qualified"},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Lead '22222222-2222-2222-2222-222222222222' not found."
|
||||
|
||||
|
||||
def test_update_opportunity_mutates_canonical_deal_for_authenticated_tenant() -> None:
|
||||
pool = FakePool()
|
||||
client = _build_shared_app(pool, "tenant_alpha")
|
||||
|
||||
response = client.patch(
|
||||
"/api/crm/opportunities/55555555-5555-5555-5555-555555555555",
|
||||
json={
|
||||
"stage": "negotiation",
|
||||
"probability": 75,
|
||||
"next_action": "Schedule commercial review",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["data"]["stage"] == "negotiation"
|
||||
assert payload["data"]["probability"] == 75
|
||||
assert payload["data"]["next_action"] == "Schedule commercial review"
|
||||
assert payload["data"]["client_name"] == "Amina Rahman"
|
||||
assert payload["meta"]["previous_stage"] == "proposal"
|
||||
assert payload["meta"]["changed"] is True
|
||||
assert pool.conn.opportunities["55555555-5555-5555-5555-555555555555"]["stage"] == "negotiation"
|
||||
|
||||
|
||||
def test_update_opportunity_rejects_cross_tenant_reference() -> None:
|
||||
pool = FakePool()
|
||||
client = _build_shared_app(pool, "tenant_beta")
|
||||
|
||||
response = client.patch(
|
||||
"/api/crm/opportunities/55555555-5555-5555-5555-555555555555",
|
||||
json={"stage": "negotiation"},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Opportunity '55555555-5555-5555-5555-555555555555' not found."
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
@@ -7,7 +8,10 @@ from typing import Any
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
os.environ.setdefault("VELOCITY_JWT_SECRET", "test-secret")
|
||||
|
||||
from backend.api.routes_crm import analytics_router, crm_router
|
||||
from backend.auth.dependencies import UserPrincipal, get_current_user
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
@@ -23,8 +27,36 @@ class FakeConn:
|
||||
normalized = query.strip()
|
||||
if "CREATE TABLE IF NOT EXISTS leads" in normalized or "CREATE TABLE IF NOT EXISTS chat_logs" in normalized:
|
||||
return "CREATE"
|
||||
if normalized.startswith("ALTER TABLE leads ADD COLUMN IF NOT EXISTS tenant_id"):
|
||||
for lead in self.leads.values():
|
||||
lead.setdefault("tenant_id", "tenant_velocity")
|
||||
return "ALTER TABLE"
|
||||
if normalized.startswith("ALTER TABLE chat_logs ADD COLUMN IF NOT EXISTS tenant_id"):
|
||||
for log in self.chat_logs.values():
|
||||
log.setdefault("tenant_id", "tenant_velocity")
|
||||
return "ALTER TABLE"
|
||||
if normalized.startswith("UPDATE leads") and "SET tenant_id = $1" in normalized:
|
||||
for lead in self.leads.values():
|
||||
if not lead.get("tenant_id"):
|
||||
lead["tenant_id"] = args[0]
|
||||
return "UPDATE"
|
||||
if normalized.startswith("UPDATE chat_logs") and "SET tenant_id = $1" in normalized:
|
||||
for log in self.chat_logs.values():
|
||||
if not log.get("tenant_id"):
|
||||
log["tenant_id"] = args[0]
|
||||
return "UPDATE"
|
||||
if normalized.startswith("ALTER TABLE leads ALTER COLUMN tenant_id SET DEFAULT"):
|
||||
return "ALTER TABLE"
|
||||
if normalized.startswith("ALTER TABLE chat_logs ALTER COLUMN tenant_id SET DEFAULT"):
|
||||
return "ALTER TABLE"
|
||||
if "CREATE INDEX IF NOT EXISTS" in normalized:
|
||||
return "CREATE INDEX"
|
||||
if normalized.startswith("DELETE FROM leads WHERE id = $1 AND tenant_id = $2"):
|
||||
existed = self.leads.get(args[0])
|
||||
if existed and existed["tenant_id"] == args[1]:
|
||||
self.leads.pop(args[0], None)
|
||||
return "DELETE 1"
|
||||
return "DELETE 0"
|
||||
if normalized.startswith("DELETE FROM leads WHERE id = $1"):
|
||||
existed = self.leads.pop(args[0], None)
|
||||
return "DELETE 1" if existed else "DELETE 0"
|
||||
@@ -33,18 +65,22 @@ class FakeConn:
|
||||
async def fetchrow(self, query: str, *args):
|
||||
normalized = query.strip()
|
||||
if "INSERT INTO leads" in normalized:
|
||||
has_tenant = "tenant_id" in normalized.split("(", 1)[1].split(")", 1)[0]
|
||||
tenant_id = args[1] if has_tenant else "tenant_velocity"
|
||||
base = 2 if has_tenant else 1
|
||||
row = {
|
||||
"id": args[0],
|
||||
"name": args[1],
|
||||
"email": args[2],
|
||||
"phone": args[3],
|
||||
"source": args[4],
|
||||
"notes": args[5],
|
||||
"qualification": args[6],
|
||||
"score": args[7],
|
||||
"kanban_status": args[8],
|
||||
"budget": args[9],
|
||||
"unit_interest": args[10],
|
||||
"tenant_id": tenant_id,
|
||||
"name": args[base],
|
||||
"email": args[base + 1],
|
||||
"phone": args[base + 2],
|
||||
"source": args[base + 3],
|
||||
"notes": args[base + 4],
|
||||
"qualification": args[base + 5],
|
||||
"score": args[base + 6],
|
||||
"kanban_status": args[base + 7],
|
||||
"budget": args[base + 8],
|
||||
"unit_interest": args[base + 9],
|
||||
"metadata": {},
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
@@ -53,7 +89,7 @@ class FakeConn:
|
||||
return row
|
||||
if normalized.startswith("UPDATE leads") and "SET kanban_status" in normalized:
|
||||
lead = self.leads.get(args[0])
|
||||
if not lead:
|
||||
if not lead or lead["tenant_id"] != args[2]:
|
||||
return None
|
||||
lead["kanban_status"] = args[1]
|
||||
lead["updated_at"] = _now()
|
||||
@@ -66,7 +102,7 @@ class FakeConn:
|
||||
return lead
|
||||
if normalized.startswith("UPDATE leads") and "RETURNING" in normalized:
|
||||
lead = self.leads.get(args[0])
|
||||
if not lead:
|
||||
if not lead or lead["tenant_id"] != args[12]:
|
||||
return None
|
||||
lead.update(
|
||||
{
|
||||
@@ -84,38 +120,47 @@ class FakeConn:
|
||||
}
|
||||
)
|
||||
return lead
|
||||
if normalized.startswith("SELECT id FROM leads WHERE id = $1"):
|
||||
if normalized.startswith("SELECT id FROM leads WHERE id = $1 AND tenant_id = $2"):
|
||||
lead = self.leads.get(args[0])
|
||||
return {"id": lead["id"]} if lead else None
|
||||
return {"id": lead["id"]} if lead and lead["tenant_id"] == args[1] else None
|
||||
if "INSERT INTO chat_logs" in normalized:
|
||||
has_tenant = "tenant_id" in normalized.split("(", 1)[1].split(")", 1)[0]
|
||||
tenant_id = args[1] if has_tenant else "tenant_velocity"
|
||||
base = 2 if has_tenant else 1
|
||||
row = {
|
||||
"id": args[0],
|
||||
"lead_id": args[1],
|
||||
"sender": args[2],
|
||||
"channel": args[3],
|
||||
"content": args[4],
|
||||
"tenant_id": tenant_id,
|
||||
"lead_id": args[base],
|
||||
"sender": args[base + 1],
|
||||
"channel": args[base + 2],
|
||||
"content": args[base + 3],
|
||||
"metadata": {},
|
||||
"created_at": _now(),
|
||||
}
|
||||
self.chat_logs[row["id"]] = row
|
||||
return row
|
||||
if "FROM leads" in normalized and "WHERE id = $1 AND tenant_id = $2" in normalized:
|
||||
lead = self.leads.get(args[0])
|
||||
return lead if lead and lead["tenant_id"] == args[1] else None
|
||||
raise AssertionError(f"Unexpected fetchrow query: {query}")
|
||||
|
||||
async def fetch(self, query: str, *args):
|
||||
normalized = query.strip()
|
||||
if "FROM leads" in normalized and "GROUP BY source" not in normalized and "GROUP BY qualification" not in normalized:
|
||||
rows = list(self.leads.values())
|
||||
if "WHERE kanban_status = $1" in normalized:
|
||||
rows = [row for row in rows if row["kanban_status"] == args[0]]
|
||||
rows = [row for row in self.leads.values() if row["tenant_id"] == args[0]]
|
||||
if "WHERE tenant_id = $1 AND kanban_status = $2" in normalized:
|
||||
rows = [row for row in rows if row["kanban_status"] == args[1]]
|
||||
return rows
|
||||
if "FROM chat_logs" in normalized:
|
||||
rows = list(self.chat_logs.values())
|
||||
if "WHERE lead_id = $1" in normalized:
|
||||
rows = [row for row in rows if row["lead_id"] == args[0]]
|
||||
rows = [row for row in self.chat_logs.values() if row["tenant_id"] == args[0]]
|
||||
if "WHERE tenant_id = $1 AND lead_id = $2" in normalized:
|
||||
rows = [row for row in rows if row["lead_id"] == args[1]]
|
||||
return rows
|
||||
if "GROUP BY source" in normalized:
|
||||
grouped: dict[str, dict[str, Any]] = {}
|
||||
for lead in self.leads.values():
|
||||
if lead["tenant_id"] != args[0]:
|
||||
continue
|
||||
slot = grouped.setdefault(lead["source"], {"source": lead["source"], "lead_count": 0, "avg_score": 0.0})
|
||||
slot["lead_count"] += 1
|
||||
slot["avg_score"] += float(lead["score"])
|
||||
@@ -125,6 +170,8 @@ class FakeConn:
|
||||
if "GROUP BY qualification" in normalized:
|
||||
grouped: dict[str, dict[str, Any]] = {}
|
||||
for lead in self.leads.values():
|
||||
if lead["tenant_id"] != args[0]:
|
||||
continue
|
||||
slot = grouped.setdefault(lead["qualification"], {"qualification": lead["qualification"], "lead_count": 0})
|
||||
slot["lead_count"] += 1
|
||||
return list(grouped.values())
|
||||
@@ -140,15 +187,34 @@ class FakePool:
|
||||
yield self.conn
|
||||
|
||||
|
||||
def _build_client() -> tuple[TestClient, FakePool]:
|
||||
def _build_client(
|
||||
*,
|
||||
authenticated: bool = True,
|
||||
tenant_id: str = "tenant_velocity",
|
||||
) -> tuple[TestClient, FakePool]:
|
||||
app = FastAPI()
|
||||
pool = FakePool()
|
||||
app.state.db_pool = pool
|
||||
app.include_router(crm_router, prefix="/api")
|
||||
app.include_router(analytics_router, prefix="/api/analytics")
|
||||
if authenticated:
|
||||
app.dependency_overrides[get_current_user] = lambda: UserPrincipal("user-1", "ADMIN", tenant_id)
|
||||
return TestClient(app), pool
|
||||
|
||||
|
||||
def _build_shared_app(pool: FakePool, current_user: dict[str, str]) -> TestClient:
|
||||
app = FastAPI()
|
||||
app.state.db_pool = pool
|
||||
app.include_router(crm_router, prefix="/api")
|
||||
app.include_router(analytics_router, prefix="/api/analytics")
|
||||
app.dependency_overrides[get_current_user] = lambda: UserPrincipal(
|
||||
"user-1",
|
||||
current_user["role"],
|
||||
current_user["tenant_id"],
|
||||
)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_crm_crud_and_analytics_flow() -> None:
|
||||
client, _pool = _build_client()
|
||||
|
||||
@@ -213,3 +279,42 @@ def test_lead_demographics_groups_by_source_and_qualification() -> None:
|
||||
payload = response.json()["data"]
|
||||
assert len(payload["by_source"]) == 2
|
||||
assert any(row["qualification"] == "POTENTIAL" for row in payload["by_qualification"])
|
||||
|
||||
|
||||
def test_crm_routes_require_authentication() -> None:
|
||||
client, _pool = _build_client(authenticated=False)
|
||||
|
||||
response = client.get("/api/leads")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == "Missing or malformed Authorization header."
|
||||
|
||||
|
||||
def test_crm_routes_are_scoped_to_authenticated_tenant() -> None:
|
||||
pool = FakePool()
|
||||
tenant_a = {"role": "ADMIN", "tenant_id": "tenant_alpha"}
|
||||
client_a = _build_shared_app(pool, tenant_a)
|
||||
|
||||
create_response = client_a.post(
|
||||
"/api/leads",
|
||||
json={"name": "Tenant Alpha Lead", "source": "website", "score": 88},
|
||||
)
|
||||
assert create_response.status_code == 201
|
||||
lead_id = create_response.json()["data"]["id"]
|
||||
|
||||
tenant_b = {"role": "ADMIN", "tenant_id": "tenant_beta"}
|
||||
client_b = _build_shared_app(pool, tenant_b)
|
||||
|
||||
list_response = client_b.get("/api/leads")
|
||||
assert list_response.status_code == 200
|
||||
assert list_response.json()["meta"]["count"] == 0
|
||||
|
||||
get_response = client_b.get(f"/api/leads/{lead_id}")
|
||||
assert get_response.status_code == 404
|
||||
|
||||
delete_response = client_b.delete(f"/api/leads/{lead_id}")
|
||||
assert delete_response.status_code == 404
|
||||
|
||||
own_list_response = client_a.get("/api/leads")
|
||||
assert own_list_response.status_code == 200
|
||||
assert own_list_response.json()["meta"]["count"] == 1
|
||||
|
||||
30
backend/tests/test_dream_weaver_gateway_auth.py
Normal file
30
backend/tests/test_dream_weaver_gateway_auth.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from comfy_engine.scripts.gateway_auth import (
|
||||
extract_gateway_api_key,
|
||||
is_gateway_request_authorized,
|
||||
load_gateway_api_key,
|
||||
)
|
||||
|
||||
|
||||
def test_load_gateway_api_key_prefers_explicit_gateway_env() -> None:
|
||||
env = {
|
||||
"DREAM_WEAVER_API_KEY": "fallback-key",
|
||||
"DREAM_WEAVER_GATEWAY_API_KEY": "primary-key",
|
||||
}
|
||||
assert load_gateway_api_key(env) == "primary-key"
|
||||
|
||||
|
||||
def test_extract_gateway_api_key_supports_dedicated_headers() -> None:
|
||||
assert extract_gateway_api_key({"x-dream-weaver-api-key": "dw-key"}) == "dw-key"
|
||||
assert extract_gateway_api_key({"x-api-key": "legacy-key"}) == "legacy-key"
|
||||
|
||||
|
||||
def test_extract_gateway_api_key_supports_bearer_authorization() -> None:
|
||||
assert extract_gateway_api_key({"authorization": "Bearer shared-key"}) == "shared-key"
|
||||
|
||||
|
||||
def test_gateway_auth_allows_open_gateways_and_blocks_wrong_keys() -> None:
|
||||
assert is_gateway_request_authorized({}, None) is True
|
||||
assert is_gateway_request_authorized({"x-dream-weaver-api-key": "correct"}, "correct") is True
|
||||
assert is_gateway_request_authorized({"authorization": "Bearer wrong"}, "correct") is False
|
||||
238
backend/tests/test_legacy_crm_canonical_bridge.py
Normal file
238
backend/tests/test_legacy_crm_canonical_bridge.py
Normal file
@@ -0,0 +1,238 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
os.environ.setdefault("VELOCITY_JWT_SECRET", "test-secret")
|
||||
|
||||
from backend.api import routes_crm
|
||||
from backend.auth.dependencies import UserPrincipal, get_current_user
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class FakeConn:
|
||||
def __init__(self) -> None:
|
||||
self.leads: dict[str, dict[str, Any]] = {
|
||||
"legacy-1": {
|
||||
"id": "legacy-1",
|
||||
"tenant_id": "tenant_alpha",
|
||||
"name": "Legacy Duplicate",
|
||||
"email": "legacy.duplicate@example.com",
|
||||
"phone": "+971500000001",
|
||||
"source": "website",
|
||||
"notes": "Old legacy note",
|
||||
"qualification": "HOT",
|
||||
"score": 82,
|
||||
"kanban_status": "Qualifying",
|
||||
"budget": "AED 5M",
|
||||
"unit_interest": "Legacy Tower",
|
||||
"metadata": {},
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
},
|
||||
"legacy-2": {
|
||||
"id": "legacy-2",
|
||||
"tenant_id": "tenant_alpha",
|
||||
"name": "Legacy Only",
|
||||
"email": "legacy.only@example.com",
|
||||
"phone": "+971500000002",
|
||||
"source": "walkin",
|
||||
"notes": "Pure legacy lead",
|
||||
"qualification": "POTENTIAL",
|
||||
"score": 74,
|
||||
"kanban_status": "Negotiation",
|
||||
"budget": "AED 7M",
|
||||
"unit_interest": "Legacy Residence",
|
||||
"metadata": {},
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
},
|
||||
}
|
||||
self.chat_logs: dict[str, dict[str, Any]] = {}
|
||||
|
||||
async def execute(self, query: str, *args):
|
||||
normalized = " ".join(query.strip().split())
|
||||
if "CREATE TABLE IF NOT EXISTS leads" in normalized or "CREATE TABLE IF NOT EXISTS chat_logs" in normalized:
|
||||
return "CREATE"
|
||||
if normalized.startswith("ALTER TABLE leads ADD COLUMN IF NOT EXISTS tenant_id"):
|
||||
return "ALTER TABLE"
|
||||
if normalized.startswith("ALTER TABLE chat_logs ADD COLUMN IF NOT EXISTS tenant_id"):
|
||||
return "ALTER TABLE"
|
||||
if normalized.startswith("UPDATE leads SET tenant_id = $1 WHERE tenant_id IS NULL OR tenant_id = ''"):
|
||||
return "UPDATE"
|
||||
if normalized.startswith("UPDATE chat_logs SET tenant_id = $1 WHERE tenant_id IS NULL OR tenant_id = ''"):
|
||||
return "UPDATE"
|
||||
if normalized.startswith("ALTER TABLE leads ALTER COLUMN tenant_id SET DEFAULT"):
|
||||
return "ALTER TABLE"
|
||||
if normalized.startswith("ALTER TABLE chat_logs ALTER COLUMN tenant_id SET DEFAULT"):
|
||||
return "ALTER TABLE"
|
||||
if "CREATE INDEX IF NOT EXISTS" in normalized:
|
||||
return "CREATE INDEX"
|
||||
raise AssertionError(f"Unexpected execute query: {query}")
|
||||
|
||||
async def fetch(self, query: str, *args):
|
||||
normalized = " ".join(query.strip().split())
|
||||
if "FROM leads" in normalized:
|
||||
rows = [row for row in self.leads.values() if row["tenant_id"] == args[0]]
|
||||
return rows
|
||||
if "FROM chat_logs" in normalized:
|
||||
rows = [row for row in self.chat_logs.values() if row["tenant_id"] == args[0]]
|
||||
if len(args) >= 2:
|
||||
rows = [row for row in rows if row["lead_id"] == args[1]]
|
||||
return rows
|
||||
raise AssertionError(f"Unexpected fetch query: {query}")
|
||||
|
||||
async def fetchrow(self, query: str, *args):
|
||||
normalized = " ".join(query.strip().split())
|
||||
if "FROM leads" in normalized and "WHERE id = $1 AND tenant_id = $2" in normalized:
|
||||
row = self.leads.get(args[0])
|
||||
return row if row and row["tenant_id"] == args[1] else None
|
||||
raise AssertionError(f"Unexpected fetchrow query: {query}")
|
||||
|
||||
|
||||
class FakePool:
|
||||
def __init__(self) -> None:
|
||||
self.conn = FakeConn()
|
||||
|
||||
@asynccontextmanager
|
||||
async def acquire(self):
|
||||
yield self.conn
|
||||
|
||||
|
||||
def _build_client() -> TestClient:
|
||||
app = FastAPI()
|
||||
app.state.db_pool = FakePool()
|
||||
app.include_router(routes_crm.crm_router, prefix="/api")
|
||||
app.dependency_overrides[get_current_user] = lambda: UserPrincipal(
|
||||
"user-1",
|
||||
"ADMIN",
|
||||
"tenant_alpha",
|
||||
)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_list_leads_merges_canonical_and_legacy_without_duplicate_shadow(monkeypatch) -> None:
|
||||
client = _build_client()
|
||||
|
||||
async def fake_fetch_canonical_leads(conn: Any, tenant_id: str, search: str | None = None) -> list[dict[str, Any]]:
|
||||
assert tenant_id == "tenant_alpha"
|
||||
return [
|
||||
{
|
||||
"id": "legacy-1",
|
||||
"name": "Canonical Preferred",
|
||||
"email": "canonical@example.com",
|
||||
"phone": "+971500009999",
|
||||
"source": "website",
|
||||
"notes": "Canonical note",
|
||||
"qualification": "WHALE",
|
||||
"score": 96,
|
||||
"kanban_status": "Negotiation",
|
||||
"stage": "negotiation",
|
||||
"budget": "AED 18M",
|
||||
"unit_interest": "Sky Deck Penthouse",
|
||||
"metadata": {
|
||||
"legacy_lead_id": "legacy-1",
|
||||
"canonical_lead_id": "canon-1",
|
||||
"canonical_person_id": "person-1",
|
||||
},
|
||||
"created_at": "2026-04-22T10:00:00+00:00",
|
||||
"updated_at": "2026-04-22T11:00:00+00:00",
|
||||
}
|
||||
]
|
||||
|
||||
monkeypatch.setattr(routes_crm, "_fetch_canonical_leads", fake_fetch_canonical_leads)
|
||||
|
||||
response = client.get("/api/leads")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()["data"]
|
||||
assert len(payload) == 2
|
||||
assert payload[0]["name"] == "Canonical Preferred"
|
||||
assert payload[1]["id"] == "legacy-2"
|
||||
|
||||
|
||||
def test_get_lead_resolves_canonical_record_by_canonical_id(monkeypatch) -> None:
|
||||
client = _build_client()
|
||||
|
||||
async def fake_fetch_canonical_leads(conn: Any, tenant_id: str, search: str | None = None) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"id": "legacy-1",
|
||||
"name": "Canonical Preferred",
|
||||
"email": "canonical@example.com",
|
||||
"phone": "+971500009999",
|
||||
"source": "website",
|
||||
"notes": "Canonical note",
|
||||
"qualification": "WHALE",
|
||||
"score": 96,
|
||||
"kanban_status": "Negotiation",
|
||||
"stage": "negotiation",
|
||||
"budget": "AED 18M",
|
||||
"unit_interest": "Sky Deck Penthouse",
|
||||
"metadata": {
|
||||
"legacy_lead_id": "legacy-1",
|
||||
"canonical_lead_id": "canon-1",
|
||||
"canonical_person_id": "person-1",
|
||||
},
|
||||
"created_at": "2026-04-22T10:00:00+00:00",
|
||||
"updated_at": "2026-04-22T11:00:00+00:00",
|
||||
}
|
||||
]
|
||||
|
||||
monkeypatch.setattr(routes_crm, "_fetch_canonical_leads", fake_fetch_canonical_leads)
|
||||
|
||||
response = client.get("/api/leads/canon-1")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["data"]["name"] == "Canonical Preferred"
|
||||
|
||||
|
||||
def test_chat_logs_fall_back_to_canonical_interactions(monkeypatch) -> None:
|
||||
client = _build_client()
|
||||
|
||||
async def fake_fetch_canonical_chat_logs(conn: Any, tenant_id: str, lead_id: str, channel: str | None = None) -> list[dict[str, Any]]:
|
||||
assert tenant_id == "tenant_alpha"
|
||||
assert lead_id == "canon-1"
|
||||
return [
|
||||
{
|
||||
"id": "interaction-1",
|
||||
"lead_id": "canon-1",
|
||||
"sender": "oracle",
|
||||
"channel": "whatsapp",
|
||||
"content": "Canonical interaction summary",
|
||||
"metadata": {"source_of_truth": "canonical_crm"},
|
||||
"created_at": "2026-04-22T12:00:00+00:00",
|
||||
}
|
||||
]
|
||||
|
||||
monkeypatch.setattr(routes_crm, "_fetch_canonical_chat_logs", fake_fetch_canonical_chat_logs)
|
||||
|
||||
response = client.get("/api/chat-logs", params={"lead_id": "canon-1"})
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()["data"]
|
||||
assert len(payload) == 1
|
||||
assert payload[0]["content"] == "Canonical interaction summary"
|
||||
|
||||
|
||||
def test_list_leads_falls_back_to_legacy_when_canonical_bridge_unavailable(monkeypatch) -> None:
|
||||
client = _build_client()
|
||||
|
||||
async def failing_fetch_canonical_leads(conn: Any, tenant_id: str, search: str | None = None) -> list[dict[str, Any]]:
|
||||
raise RuntimeError("canonical tables unavailable")
|
||||
|
||||
monkeypatch.setattr(routes_crm, "_fetch_canonical_leads", failing_fetch_canonical_leads)
|
||||
|
||||
response = client.get("/api/leads")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()["data"]
|
||||
assert [lead["id"] for lead in payload] == ["legacy-1", "legacy-2"]
|
||||
243
backend/tests/test_legacy_crm_write_bridge.py
Normal file
243
backend/tests/test_legacy_crm_write_bridge.py
Normal file
@@ -0,0 +1,243 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
os.environ.setdefault("VELOCITY_JWT_SECRET", "test-secret")
|
||||
|
||||
from backend.api.routes_crm import crm_router
|
||||
from backend.auth.dependencies import UserPrincipal, get_current_user
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class FakeConn:
|
||||
def __init__(self) -> None:
|
||||
self.leads: dict[str, dict[str, Any]] = {}
|
||||
self.chat_logs: dict[str, dict[str, Any]] = {}
|
||||
|
||||
async def execute(self, query: str, *args):
|
||||
normalized = " ".join(query.strip().split())
|
||||
if "CREATE TABLE IF NOT EXISTS leads" in normalized or "CREATE TABLE IF NOT EXISTS chat_logs" in normalized:
|
||||
return "CREATE"
|
||||
if normalized.startswith("ALTER TABLE leads ADD COLUMN IF NOT EXISTS tenant_id"):
|
||||
return "ALTER TABLE"
|
||||
if normalized.startswith("ALTER TABLE chat_logs ADD COLUMN IF NOT EXISTS tenant_id"):
|
||||
return "ALTER TABLE"
|
||||
if normalized.startswith("UPDATE leads SET tenant_id = $1 WHERE tenant_id IS NULL OR tenant_id = ''"):
|
||||
return "UPDATE"
|
||||
if normalized.startswith("UPDATE chat_logs SET tenant_id = $1 WHERE tenant_id IS NULL OR tenant_id = ''"):
|
||||
return "UPDATE"
|
||||
if normalized.startswith("ALTER TABLE leads ALTER COLUMN tenant_id SET DEFAULT"):
|
||||
return "ALTER TABLE"
|
||||
if normalized.startswith("ALTER TABLE chat_logs ALTER COLUMN tenant_id SET DEFAULT"):
|
||||
return "ALTER TABLE"
|
||||
if "CREATE INDEX IF NOT EXISTS" in normalized:
|
||||
return "CREATE INDEX"
|
||||
if normalized.startswith("DELETE FROM leads WHERE id = $1 AND tenant_id = $2"):
|
||||
existed = self.leads.get(args[0])
|
||||
if existed and existed["tenant_id"] == args[1]:
|
||||
self.leads.pop(args[0], None)
|
||||
return "DELETE 1"
|
||||
return "DELETE 0"
|
||||
raise AssertionError(f"Unexpected execute query: {query}")
|
||||
|
||||
async def fetchrow(self, query: str, *args):
|
||||
normalized = " ".join(query.strip().split())
|
||||
if "INSERT INTO leads" in normalized:
|
||||
row = {
|
||||
"id": args[0],
|
||||
"tenant_id": args[1],
|
||||
"name": args[2],
|
||||
"email": args[3],
|
||||
"phone": args[4],
|
||||
"source": args[5],
|
||||
"notes": args[6],
|
||||
"qualification": args[7],
|
||||
"score": args[8],
|
||||
"kanban_status": args[9],
|
||||
"budget": args[10],
|
||||
"unit_interest": args[11],
|
||||
"metadata": {},
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
}
|
||||
self.leads[row["id"]] = row
|
||||
return row
|
||||
if normalized.startswith("UPDATE leads") and "RETURNING" in normalized:
|
||||
row = self.leads.get(args[0])
|
||||
tenant_arg = args[-1]
|
||||
if not row or row["tenant_id"] != tenant_arg:
|
||||
return None
|
||||
if len(args) == 13:
|
||||
row.update(
|
||||
{
|
||||
"name": args[1],
|
||||
"email": args[2],
|
||||
"phone": args[3],
|
||||
"source": args[4],
|
||||
"notes": args[5],
|
||||
"qualification": args[6],
|
||||
"score": args[7],
|
||||
"kanban_status": args[8],
|
||||
"budget": args[9],
|
||||
"unit_interest": args[10],
|
||||
"updated_at": _now(),
|
||||
}
|
||||
)
|
||||
else:
|
||||
row.update(
|
||||
{
|
||||
"kanban_status": args[1],
|
||||
"qualification": "HOT" if row["score"] >= 45 else row["qualification"],
|
||||
"updated_at": _now(),
|
||||
}
|
||||
)
|
||||
return row
|
||||
if normalized.startswith("SELECT id, name, email, phone, source, notes, qualification, score, kanban_status, budget, unit_interest, metadata, created_at, updated_at FROM leads WHERE id = $1 AND tenant_id = $2"):
|
||||
row = self.leads.get(args[0])
|
||||
return row if row and row["tenant_id"] == args[1] else None
|
||||
if normalized.startswith("INSERT INTO chat_logs"):
|
||||
row = {
|
||||
"id": args[0],
|
||||
"tenant_id": args[1],
|
||||
"lead_id": args[2],
|
||||
"sender": args[3],
|
||||
"channel": args[4],
|
||||
"content": args[5],
|
||||
"metadata": {},
|
||||
"created_at": _now(),
|
||||
}
|
||||
self.chat_logs[row["id"]] = row
|
||||
return row
|
||||
raise AssertionError(f"Unexpected fetchrow query: {query}")
|
||||
|
||||
|
||||
class FakePool:
|
||||
def __init__(self) -> None:
|
||||
self.conn = FakeConn()
|
||||
|
||||
@asynccontextmanager
|
||||
async def acquire(self):
|
||||
yield self.conn
|
||||
|
||||
|
||||
def _build_client() -> tuple[TestClient, FakePool]:
|
||||
app = FastAPI()
|
||||
pool = FakePool()
|
||||
app.state.db_pool = pool
|
||||
app.include_router(crm_router, prefix="/api")
|
||||
app.dependency_overrides[get_current_user] = lambda: UserPrincipal("user-1", "ADMIN", "tenant_alpha")
|
||||
return TestClient(app), pool
|
||||
|
||||
|
||||
def test_create_lead_triggers_canonical_write_bridge(monkeypatch) -> None:
|
||||
client, _pool = _build_client()
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
async def fake_sync(request, conn, user, legacy_lead):
|
||||
calls.append({"lead_id": legacy_lead["id"], "name": legacy_lead["name"], "tenant_id": user.tenant_id})
|
||||
return {"person_id": "person-1", "lead_id": "canon-1"}
|
||||
|
||||
monkeypatch.setattr("backend.api.routes_crm._sync_canonical_lead_bridge", fake_sync)
|
||||
|
||||
response = client.post("/api/leads", json={"name": "Amina Rahman", "source": "website", "score": 88})
|
||||
|
||||
assert response.status_code == 201
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["name"] == "Amina Rahman"
|
||||
assert calls[0]["tenant_id"] == "tenant_alpha"
|
||||
|
||||
|
||||
def test_update_lead_triggers_canonical_write_bridge(monkeypatch) -> None:
|
||||
client, _pool = _build_client()
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
async def fake_sync(request, conn, user, legacy_lead):
|
||||
calls.append({"lead_id": legacy_lead["id"], "status": legacy_lead["kanban_status"]})
|
||||
return {"person_id": "person-1", "lead_id": "canon-1"}
|
||||
|
||||
monkeypatch.setattr("backend.api.routes_crm._sync_canonical_lead_bridge", fake_sync)
|
||||
|
||||
create_response = client.post("/api/leads", json={"name": "Lead One", "source": "website", "score": 60})
|
||||
lead_id = create_response.json()["data"]["id"]
|
||||
calls.clear()
|
||||
|
||||
update_response = client.put(
|
||||
f"/api/leads/{lead_id}",
|
||||
json={"name": "Lead One Updated", "source": "website", "score": 75, "kanban_status": "negotiation"},
|
||||
)
|
||||
|
||||
assert update_response.status_code == 200
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["lead_id"] == lead_id
|
||||
assert calls[0]["status"] == "Negotiation"
|
||||
|
||||
|
||||
def test_create_chat_log_triggers_canonical_chat_bridge(monkeypatch) -> None:
|
||||
client, _pool = _build_client()
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
async def fake_lead_sync(request, conn, user, legacy_lead):
|
||||
return {"person_id": "person-1", "lead_id": "canon-1"}
|
||||
|
||||
async def fake_chat_sync(request, conn, user, legacy_chat_log, legacy_lead):
|
||||
calls.append(
|
||||
{
|
||||
"chat_log_id": legacy_chat_log["id"],
|
||||
"lead_id": legacy_chat_log["lead_id"],
|
||||
"content": legacy_chat_log["content"],
|
||||
"lead_name": legacy_lead["name"],
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr("backend.api.routes_crm._sync_canonical_lead_bridge", fake_lead_sync)
|
||||
monkeypatch.setattr("backend.api.routes_crm._sync_canonical_chat_log_bridge", fake_chat_sync)
|
||||
|
||||
create_response = client.post("/api/leads", json={"name": "Lead One", "source": "website", "score": 60})
|
||||
lead_id = create_response.json()["data"]["id"]
|
||||
|
||||
response = client.post(
|
||||
"/api/chat-logs",
|
||||
json={"lead_id": lead_id, "sender": "oracle", "channel": "whatsapp", "content": "Follow up tonight"},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["lead_id"] == lead_id
|
||||
assert calls[0]["content"] == "Follow up tonight"
|
||||
|
||||
|
||||
def test_move_and_delete_trigger_canonical_write_bridges(monkeypatch) -> None:
|
||||
client, _pool = _build_client()
|
||||
move_calls: list[dict[str, Any]] = []
|
||||
delete_calls: list[str] = []
|
||||
|
||||
async def fake_sync(request, conn, user, legacy_lead):
|
||||
move_calls.append({"lead_id": legacy_lead["id"], "status": legacy_lead["kanban_status"]})
|
||||
return {"person_id": "person-1", "lead_id": "canon-1"}
|
||||
|
||||
async def fake_delete(request, conn, user, legacy_lead_id):
|
||||
delete_calls.append(legacy_lead_id)
|
||||
|
||||
monkeypatch.setattr("backend.api.routes_crm._sync_canonical_lead_bridge", fake_sync)
|
||||
monkeypatch.setattr("backend.api.routes_crm._delete_canonical_lead_bridge", fake_delete)
|
||||
|
||||
create_response = client.post("/api/leads", json={"name": "Lead One", "source": "website", "score": 60})
|
||||
lead_id = create_response.json()["data"]["id"]
|
||||
move_calls.clear()
|
||||
|
||||
move_response = client.put("/api/kanban/move", json={"lead_id": lead_id, "target_status": "site_visit"})
|
||||
delete_response = client.delete(f"/api/leads/{lead_id}")
|
||||
|
||||
assert move_response.status_code == 200
|
||||
assert delete_response.status_code == 200
|
||||
assert move_calls == [{"lead_id": lead_id, "status": "Site Visit"}]
|
||||
assert delete_calls == [lead_id]
|
||||
40
backend/tests/test_migrations_and_observability.py
Normal file
40
backend/tests/test_migrations_and_observability.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
os.environ.setdefault("VELOCITY_JWT_SECRET", "test-secret")
|
||||
|
||||
from backend.migrations.runner import discover_migrations
|
||||
from backend.observability import RequestObservabilityMiddleware
|
||||
|
||||
|
||||
def test_migration_discovery_is_ordered_and_checksummed() -> None:
|
||||
migrations = discover_migrations(Path("backend/migrations/versions"))
|
||||
|
||||
assert migrations
|
||||
assert migrations == sorted(migrations, key=lambda migration: migration.version)
|
||||
assert all(len(migration.checksum) == 64 for migration in migrations)
|
||||
assert len({migration.version for migration in migrations}) == len(migrations)
|
||||
|
||||
|
||||
def test_observability_middleware_adds_request_headers_and_snapshot() -> None:
|
||||
app = FastAPI()
|
||||
app.add_middleware(RequestObservabilityMiddleware)
|
||||
|
||||
@app.get("/ping")
|
||||
async def ping() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/ping", headers={"X-Request-ID": "req-test"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["X-Request-ID"] == "req-test"
|
||||
assert "X-Response-Time-Ms" in response.headers
|
||||
assert app.state.request_metrics[-1].request_id == "req-test"
|
||||
assert app.state.request_metrics[-1].path == "/ping"
|
||||
|
||||
470
backend/tests/test_surface_route_tenant_scoping.py
Normal file
470
backend/tests/test_surface_route_tenant_scoping.py
Normal file
@@ -0,0 +1,470 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
os.environ.setdefault("VELOCITY_JWT_SECRET", "test-secret")
|
||||
|
||||
from backend.api.routes_inventory import router as inventory_router
|
||||
from backend.api.routes_mobile_edge import router as mobile_edge_router
|
||||
from backend.auth.dependencies import UserPrincipal, get_current_user
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class FakeSurfaceConn:
|
||||
def __init__(self) -> None:
|
||||
self.events: dict[str, dict[str, Any]] = {}
|
||||
self.calendar_events: dict[str, dict[str, Any]] = {}
|
||||
self.properties: dict[str, dict[str, Any]] = {}
|
||||
self.import_batches: dict[str, dict[str, Any]] = {}
|
||||
|
||||
async def fetchrow(self, query: str, *args):
|
||||
normalized = " ".join(query.strip().split())
|
||||
|
||||
if "INSERT INTO edge_communication_events" in normalized:
|
||||
event_id = str(uuid.uuid4())
|
||||
row = {
|
||||
"event_id": event_id,
|
||||
"tenant_id": args[0],
|
||||
"lead_id": args[1],
|
||||
"channel": args[2],
|
||||
"direction": args[3],
|
||||
"provider": args[4],
|
||||
"capture_mode": args[5],
|
||||
"consent_state": args[6],
|
||||
"duration_seconds": args[7],
|
||||
"summary": args[8],
|
||||
"raw_reference": args[9],
|
||||
"recording_ref": args[10],
|
||||
"provider_metadata": args[11],
|
||||
"timestamp": _now(),
|
||||
"created_at": _now(),
|
||||
}
|
||||
self.events[event_id] = row
|
||||
return {"event_id": event_id, "created_at": row["created_at"]}
|
||||
|
||||
if "INSERT INTO user_calendar_events" in normalized:
|
||||
calendar_event_id = str(uuid.uuid4())
|
||||
row = {
|
||||
"calendar_event_id": calendar_event_id,
|
||||
"tenant_id": args[0],
|
||||
"owner_user_id": args[1],
|
||||
"lead_id": args[2],
|
||||
"source_event_id": args[3],
|
||||
"title": args[4],
|
||||
"description": args[5],
|
||||
"start_at": args[6],
|
||||
"end_at": args[7],
|
||||
"all_day": args[8],
|
||||
"status": args[9],
|
||||
"reminder_minutes": args[10],
|
||||
"created_by": args[11],
|
||||
"location": args[12],
|
||||
"metadata": args[13],
|
||||
"created_at": _now(),
|
||||
}
|
||||
self.calendar_events[calendar_event_id] = row
|
||||
return row
|
||||
|
||||
if "INSERT INTO inventory_import_batches" in normalized:
|
||||
batch_id = str(uuid.uuid4())
|
||||
row = {
|
||||
"batch_id": batch_id,
|
||||
"tenant_id": args[0],
|
||||
"source_type": args[1],
|
||||
"submitted_by": args[2],
|
||||
"total_rows": args[3],
|
||||
"source_file_ref": args[4],
|
||||
"accepted_rows": 0,
|
||||
"rejected_rows": 0,
|
||||
"status": "pending",
|
||||
"created_at": _now(),
|
||||
"completed_at": None,
|
||||
}
|
||||
self.import_batches[batch_id] = row
|
||||
return {
|
||||
"batch_id": batch_id,
|
||||
"status": row["status"],
|
||||
"created_at": row["created_at"],
|
||||
}
|
||||
|
||||
if "INSERT INTO inventory_properties" in normalized:
|
||||
property_id = str(uuid.uuid4())
|
||||
row = {
|
||||
"property_id": property_id,
|
||||
"tenant_id": args[0],
|
||||
"batch_id": args[1],
|
||||
"source_id": args[2],
|
||||
"project_name": args[3],
|
||||
"developer_name": args[4],
|
||||
"location": args[5],
|
||||
"property_type": args[6],
|
||||
"price_bands": args[7],
|
||||
"unit_mix": args[8],
|
||||
"amenities": args[9],
|
||||
"status": args[10],
|
||||
"validation_state": args[11],
|
||||
"ingested_at": None,
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
}
|
||||
self.properties[property_id] = row
|
||||
return {"property_id": property_id, "created_at": row["created_at"]}
|
||||
|
||||
if normalized.startswith("SELECT * FROM inventory_import_batches WHERE batch_id=$1 AND tenant_id=$2"):
|
||||
row = self.import_batches.get(args[0])
|
||||
return row if row and row["tenant_id"] == args[1] else None
|
||||
|
||||
if normalized.startswith("SELECT * FROM inventory_properties WHERE property_id=$1 AND tenant_id=$2"):
|
||||
row = self.properties.get(args[0])
|
||||
return row if row and row["tenant_id"] == args[1] else None
|
||||
|
||||
raise AssertionError(f"Unexpected fetchrow query: {query}")
|
||||
|
||||
async def fetch(self, query: str, *args):
|
||||
normalized = " ".join(query.strip().split())
|
||||
|
||||
if "FROM edge_communication_events" in normalized:
|
||||
tenant_id, lead_id, limit, offset = args
|
||||
rows = [
|
||||
{
|
||||
"event_id": row["event_id"],
|
||||
"lead_id": row["lead_id"],
|
||||
"channel": row["channel"],
|
||||
"direction": row["direction"],
|
||||
"provider": row["provider"],
|
||||
"capture_mode": row["capture_mode"],
|
||||
"consent_state": row["consent_state"],
|
||||
"timestamp": row["timestamp"].isoformat(),
|
||||
"duration_seconds": row["duration_seconds"],
|
||||
"summary": row["summary"],
|
||||
"raw_reference": row["raw_reference"],
|
||||
"recording_ref": row["recording_ref"],
|
||||
"provider_metadata": row["provider_metadata"],
|
||||
"created_at": row["created_at"].isoformat(),
|
||||
}
|
||||
for row in self.events.values()
|
||||
if row["tenant_id"] == tenant_id and row["lead_id"] == lead_id
|
||||
]
|
||||
rows.sort(key=lambda item: item["timestamp"], reverse=True)
|
||||
return rows[offset : offset + limit]
|
||||
|
||||
if "FROM user_calendar_events" in normalized:
|
||||
tenant_id = args[0]
|
||||
owner_user_id = args[1]
|
||||
limit = args[-1]
|
||||
rows = [
|
||||
{
|
||||
"calendar_event_id": row["calendar_event_id"],
|
||||
"lead_id": row["lead_id"],
|
||||
"title": row["title"],
|
||||
"description": row["description"],
|
||||
"start_at": row["start_at"],
|
||||
"end_at": row["end_at"],
|
||||
"all_day": row["all_day"],
|
||||
"status": row["status"],
|
||||
"reminder_minutes": row["reminder_minutes"],
|
||||
"created_by": row["created_by"],
|
||||
"location": row["location"],
|
||||
"metadata": row["metadata"],
|
||||
"created_at": row["created_at"].isoformat(),
|
||||
}
|
||||
for row in self.calendar_events.values()
|
||||
if row["tenant_id"] == tenant_id and row["owner_user_id"] == owner_user_id
|
||||
and row["status"] != "cancelled"
|
||||
]
|
||||
rows.sort(key=lambda item: item["start_at"])
|
||||
return rows[:limit]
|
||||
|
||||
if "FROM inventory_import_batches" in normalized:
|
||||
tenant_id, limit, offset = args
|
||||
rows = [
|
||||
{
|
||||
"batch_id": row["batch_id"],
|
||||
"source_type": row["source_type"],
|
||||
"submitted_by": row["submitted_by"],
|
||||
"status": row["status"],
|
||||
"total_rows": row["total_rows"],
|
||||
"accepted_rows": row["accepted_rows"],
|
||||
"rejected_rows": row["rejected_rows"],
|
||||
"created_at": row["created_at"].isoformat(),
|
||||
"completed_at": row["completed_at"],
|
||||
}
|
||||
for row in self.import_batches.values()
|
||||
if row["tenant_id"] == tenant_id
|
||||
]
|
||||
rows.sort(key=lambda item: item["created_at"], reverse=True)
|
||||
return rows[offset : offset + limit]
|
||||
|
||||
if "FROM inventory_properties" in normalized:
|
||||
params = list(args)
|
||||
tenant_id = params[0]
|
||||
limit = params[-2]
|
||||
offset = params[-1]
|
||||
status_filter = None
|
||||
property_type = None
|
||||
if len(params) == 4:
|
||||
status_filter = params[1]
|
||||
if len(params) == 5:
|
||||
status_filter = params[1]
|
||||
property_type = params[2]
|
||||
rows = [
|
||||
{
|
||||
"property_id": row["property_id"],
|
||||
"project_name": row["project_name"],
|
||||
"developer_name": row["developer_name"],
|
||||
"property_type": row["property_type"],
|
||||
"location": row["location"],
|
||||
"price_bands": row["price_bands"],
|
||||
"unit_mix": row["unit_mix"],
|
||||
"status": row["status"],
|
||||
"ingested_at": row["ingested_at"],
|
||||
"created_at": row["created_at"].isoformat(),
|
||||
}
|
||||
for row in self.properties.values()
|
||||
if row["tenant_id"] == tenant_id
|
||||
and (status_filter is None or row["status"] == status_filter)
|
||||
and (property_type is None or row["property_type"] == property_type)
|
||||
]
|
||||
rows.sort(key=lambda item: item["created_at"], reverse=True)
|
||||
return rows[offset : offset + limit]
|
||||
|
||||
raise AssertionError(f"Unexpected fetch query: {query}")
|
||||
|
||||
async def fetchval(self, query: str, *args):
|
||||
normalized = " ".join(query.strip().split())
|
||||
|
||||
if normalized.startswith("SELECT COUNT(*) FROM edge_communication_events WHERE tenant_id = $1 AND lead_id = $2"):
|
||||
tenant_id, lead_id = args
|
||||
return sum(
|
||||
1
|
||||
for row in self.events.values()
|
||||
if row["tenant_id"] == tenant_id and row["lead_id"] == lead_id
|
||||
)
|
||||
|
||||
if normalized.startswith("SELECT COUNT(*) FROM inventory_import_batches WHERE tenant_id=$1"):
|
||||
tenant_id = args[0]
|
||||
return sum(1 for row in self.import_batches.values() if row["tenant_id"] == tenant_id)
|
||||
|
||||
if normalized.startswith("SELECT COUNT(*) FROM inventory_properties WHERE tenant_id = $1"):
|
||||
tenant_id = args[0]
|
||||
return sum(1 for row in self.properties.values() if row["tenant_id"] == tenant_id)
|
||||
|
||||
raise AssertionError(f"Unexpected fetchval query: {query}")
|
||||
|
||||
async def execute(self, query: str, *args):
|
||||
normalized = " ".join(query.strip().split())
|
||||
|
||||
if "UPDATE user_calendar_events SET status='cancelled'" in normalized:
|
||||
tenant_id, owner_user_id, calendar_event_id = args
|
||||
row = self.calendar_events.get(calendar_event_id)
|
||||
if not row or row["tenant_id"] != tenant_id or row["owner_user_id"] != owner_user_id:
|
||||
return "UPDATE 0"
|
||||
row["status"] = "cancelled"
|
||||
return "UPDATE 1"
|
||||
|
||||
if normalized.startswith("UPDATE user_calendar_events SET"):
|
||||
tenant_id = args[-3]
|
||||
owner_user_id = args[-2]
|
||||
calendar_event_id = args[-1]
|
||||
row = self.calendar_events.get(calendar_event_id)
|
||||
if not row or row["tenant_id"] != tenant_id or row["owner_user_id"] != owner_user_id:
|
||||
return "UPDATE 0"
|
||||
assignments = normalized.split(" SET ", 1)[1].split(" WHERE ", 1)[0].split(", ")
|
||||
for assignment, value in zip(assignments, args):
|
||||
column = assignment.split(" = ", 1)[0]
|
||||
if column not in {"tenant_id", "owner_user_id"}:
|
||||
row[column] = value
|
||||
return "UPDATE 1"
|
||||
|
||||
raise AssertionError(f"Unexpected execute query: {query}")
|
||||
|
||||
|
||||
class FakeSurfacePool:
|
||||
def __init__(self) -> None:
|
||||
self.conn = FakeSurfaceConn()
|
||||
|
||||
@asynccontextmanager
|
||||
async def acquire(self):
|
||||
yield self.conn
|
||||
|
||||
|
||||
def _build_shared_app(pool: FakeSurfacePool, current_user: dict[str, str]) -> TestClient:
|
||||
app = FastAPI()
|
||||
app.state.db_pool = pool
|
||||
app.include_router(mobile_edge_router, prefix="/api/mobile-edge")
|
||||
app.include_router(inventory_router, prefix="/api/inventory")
|
||||
app.dependency_overrides[get_current_user] = lambda: UserPrincipal(
|
||||
current_user["user_id"],
|
||||
current_user["role"],
|
||||
current_user["tenant_id"],
|
||||
)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_mobile_edge_event_routes_scope_by_tenant_id_instead_of_role() -> None:
|
||||
pool = FakeSurfacePool()
|
||||
tenant_a = _build_shared_app(
|
||||
pool,
|
||||
{"user_id": "user-a", "role": "ADMIN", "tenant_id": "tenant_alpha"},
|
||||
)
|
||||
tenant_b = _build_shared_app(
|
||||
pool,
|
||||
{"user_id": "user-b", "role": "ADMIN", "tenant_id": "tenant_beta"},
|
||||
)
|
||||
|
||||
create_response = tenant_a.post(
|
||||
"/api/mobile-edge/events",
|
||||
json={
|
||||
"lead_id": "lead-123",
|
||||
"channel": "whatsapp_message",
|
||||
"direction": "inbound",
|
||||
"capture_mode": "operator_note",
|
||||
"consent_state": "granted",
|
||||
"summary": "Client asked for a marina brochure.",
|
||||
},
|
||||
)
|
||||
assert create_response.status_code == 201
|
||||
|
||||
tenant_a_events = tenant_a.get("/api/mobile-edge/events", params={"lead_id": "lead-123", "limit": 10})
|
||||
assert tenant_a_events.status_code == 200
|
||||
assert tenant_a_events.json()["total"] == 1
|
||||
|
||||
tenant_b_events = tenant_b.get("/api/mobile-edge/events", params={"lead_id": "lead-123", "limit": 10})
|
||||
assert tenant_b_events.status_code == 200
|
||||
assert tenant_b_events.json()["total"] == 0
|
||||
assert tenant_b_events.json()["events"] == []
|
||||
|
||||
|
||||
def test_mobile_edge_calendar_routes_scope_by_tenant_id_instead_of_role() -> None:
|
||||
pool = FakeSurfacePool()
|
||||
tenant_a = _build_shared_app(
|
||||
pool,
|
||||
{"user_id": "user-a", "role": "ADMIN", "tenant_id": "tenant_alpha"},
|
||||
)
|
||||
tenant_b = _build_shared_app(
|
||||
pool,
|
||||
{"user_id": "user-b", "role": "ADMIN", "tenant_id": "tenant_beta"},
|
||||
)
|
||||
|
||||
create_response = tenant_a.post(
|
||||
"/api/mobile-edge/calendar",
|
||||
json={
|
||||
"lead_id": "lead-123",
|
||||
"title": "Private site visit",
|
||||
"description": "Walkthrough with the lead",
|
||||
"start_at": "2026-04-23T10:00:00Z",
|
||||
"end_at": "2026-04-23T11:00:00Z",
|
||||
"all_day": False,
|
||||
"status": "tentative",
|
||||
"reminder_minutes": [15],
|
||||
"location": "Dubai Marina",
|
||||
"metadata": {"source": "ipad"},
|
||||
},
|
||||
)
|
||||
assert create_response.status_code == 201
|
||||
created_payload = create_response.json()
|
||||
assert created_payload["status"] == "ok"
|
||||
assert created_payload["event"]["title"] == "Private site visit"
|
||||
assert created_payload["event"]["location"] == "Dubai Marina"
|
||||
assert created_payload["event"]["status"] == "tentative"
|
||||
assert created_payload["event"]["reminder_minutes"] == [15]
|
||||
|
||||
tenant_a_calendar = tenant_a.get("/api/mobile-edge/calendar")
|
||||
assert tenant_a_calendar.status_code == 200
|
||||
assert len(tenant_a_calendar.json()["events"]) == 1
|
||||
|
||||
event_id = created_payload["event"]["calendar_event_id"]
|
||||
update_response = tenant_a.patch(
|
||||
f"/api/mobile-edge/calendar/{event_id}",
|
||||
json={"status": "confirmed"},
|
||||
)
|
||||
assert update_response.status_code == 200
|
||||
tenant_a_calendar = tenant_a.get("/api/mobile-edge/calendar")
|
||||
assert tenant_a_calendar.json()["events"][0]["status"] == "confirmed"
|
||||
|
||||
cancel_response = tenant_a.delete(f"/api/mobile-edge/calendar/{event_id}")
|
||||
assert cancel_response.status_code == 200
|
||||
tenant_a_calendar = tenant_a.get("/api/mobile-edge/calendar")
|
||||
assert tenant_a_calendar.json()["events"] == []
|
||||
|
||||
tenant_b_calendar = tenant_b.get("/api/mobile-edge/calendar")
|
||||
assert tenant_b_calendar.status_code == 200
|
||||
assert tenant_b_calendar.json()["events"] == []
|
||||
|
||||
|
||||
def test_inventory_property_routes_scope_by_tenant_id_instead_of_role() -> None:
|
||||
pool = FakeSurfacePool()
|
||||
tenant_a = _build_shared_app(
|
||||
pool,
|
||||
{"user_id": "user-a", "role": "ADMIN", "tenant_id": "tenant_alpha"},
|
||||
)
|
||||
tenant_b = _build_shared_app(
|
||||
pool,
|
||||
{"user_id": "user-b", "role": "ADMIN", "tenant_id": "tenant_beta"},
|
||||
)
|
||||
|
||||
create_response = tenant_a.post(
|
||||
"/api/inventory/properties",
|
||||
json={
|
||||
"project_name": "Marina One",
|
||||
"developer_name": "Desi Neuron Estates",
|
||||
"location": {"city": "Dubai", "district": "Marina"},
|
||||
"property_type": "apartment",
|
||||
"price_bands": [{"label": "from", "amount": 2400000}],
|
||||
"unit_mix": [{"type": "2BR", "count": 18}],
|
||||
"amenities": ["pool", "gym"],
|
||||
"status": "active",
|
||||
"validation_state": {"validated": True},
|
||||
},
|
||||
)
|
||||
assert create_response.status_code == 201
|
||||
|
||||
tenant_a_properties = tenant_a.get("/api/inventory/properties", params={"limit": 20})
|
||||
assert tenant_a_properties.status_code == 200
|
||||
assert tenant_a_properties.json()["total"] == 1
|
||||
|
||||
tenant_b_properties = tenant_b.get("/api/inventory/properties", params={"limit": 20})
|
||||
assert tenant_b_properties.status_code == 200
|
||||
assert tenant_b_properties.json()["total"] == 0
|
||||
assert tenant_b_properties.json()["properties"] == []
|
||||
|
||||
|
||||
def test_inventory_import_batch_routes_scope_by_tenant_id_instead_of_role() -> None:
|
||||
pool = FakeSurfacePool()
|
||||
tenant_a = _build_shared_app(
|
||||
pool,
|
||||
{"user_id": "user-a", "role": "ADMIN", "tenant_id": "tenant_alpha"},
|
||||
)
|
||||
tenant_b = _build_shared_app(
|
||||
pool,
|
||||
{"user_id": "user-b", "role": "ADMIN", "tenant_id": "tenant_beta"},
|
||||
)
|
||||
|
||||
create_response = tenant_a.post(
|
||||
"/api/inventory/import-batches",
|
||||
json={
|
||||
"source_type": "csv",
|
||||
"source_file_ref": "s3://velocity/imports/marina.csv",
|
||||
"total_rows": 24,
|
||||
},
|
||||
)
|
||||
assert create_response.status_code == 201
|
||||
|
||||
tenant_a_batches = tenant_a.get("/api/inventory/import-batches", params={"limit": 20})
|
||||
assert tenant_a_batches.status_code == 200
|
||||
assert tenant_a_batches.json()["total"] == 1
|
||||
|
||||
tenant_b_batches = tenant_b.get("/api/inventory/import-batches", params={"limit": 20})
|
||||
assert tenant_b_batches.status_code == 200
|
||||
assert tenant_b_batches.json()["total"] == 0
|
||||
assert tenant_b_batches.json()["batches"] == []
|
||||
@@ -19,12 +19,13 @@ import asyncio, json, time, uuid, io, sys, os, logging, traceback
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
|
||||
import httpx
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, UploadFile, File, HTTPException, Form, BackgroundTasks
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
import httpx
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, UploadFile, File, HTTPException, Form, Request
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
from gateway_auth import load_gateway_api_key, is_gateway_request_authorized
|
||||
|
||||
# Add scripts dir to path so we can import prompt_expander
|
||||
SCRIPTS_DIR = Path(__file__).parent / "scripts"
|
||||
@@ -38,10 +39,48 @@ except ImportError:
|
||||
logging.warning("prompt_expander not found — LLM expansion disabled")
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
logger = logging.getLogger("DreamWeaverGateway")
|
||||
|
||||
COMFY = "http://127.0.0.1:8118"
|
||||
COMFY_ROOT = "/opt/dlami/nvme/ComfyUI"
|
||||
logger = logging.getLogger("DreamWeaverGateway")
|
||||
|
||||
COMFY = (os.environ.get("COMFYUI_URL") or os.environ.get("COMFY_URL") or "http://127.0.0.1:8118").rstrip("/")
|
||||
COMFY_TLS_VERIFY = os.environ.get("COMFYUI_TLS_VERIFY", "true").strip().lower() not in {"0", "false", "no", "off"}
|
||||
COMFY_ROOT = "/opt/dlami/nvme/ComfyUI"
|
||||
GATEWAY_API_KEY = load_gateway_api_key()
|
||||
PREFERRED_CHECKPOINTS = [
|
||||
"realvisxlV50_v50LightningBakedvae.safetensors",
|
||||
"realvisxlV50Lightning_v50Lightning.safetensors",
|
||||
]
|
||||
|
||||
|
||||
def comfy_client(timeout: float = 30) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(timeout=timeout, verify=COMFY_TLS_VERIFY, follow_redirects=True)
|
||||
|
||||
|
||||
async def list_comfy_checkpoints() -> list[str]:
|
||||
async with comfy_client(timeout=10) as client:
|
||||
response = await client.get(f"{COMFY}/models/checkpoints")
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if isinstance(payload, list):
|
||||
return [item for item in payload if isinstance(item, str)]
|
||||
return []
|
||||
|
||||
|
||||
async def resolve_checkpoint() -> str:
|
||||
checkpoints = await list_comfy_checkpoints()
|
||||
if not checkpoints:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=(
|
||||
"ComfyUI is online but has no checkpoint models installed. "
|
||||
"Hydrate RealVisXL into ComfyUI/models/checkpoints before generating."
|
||||
),
|
||||
)
|
||||
lower_lookup = {item.lower(): item for item in checkpoints}
|
||||
for preferred in PREFERRED_CHECKPOINTS:
|
||||
match = lower_lookup.get(preferred.lower())
|
||||
if match:
|
||||
return match
|
||||
return checkpoints[0]
|
||||
|
||||
app = FastAPI(
|
||||
title="Dream Weaver API v2",
|
||||
@@ -51,7 +90,13 @@ app = FastAPI(
|
||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
||||
|
||||
# In-memory job store (swap for Redis in production)
|
||||
jobs: dict = {}
|
||||
jobs: dict = {}
|
||||
|
||||
|
||||
def ensure_gateway_auth(request: Request) -> None:
|
||||
if is_gateway_request_authorized(request.headers, GATEWAY_API_KEY):
|
||||
return
|
||||
raise HTTPException(status_code=401, detail="Dream Weaver gateway API key is required or invalid.")
|
||||
|
||||
|
||||
# ─── Models ──────────────────────────────────────────────────────────────────
|
||||
@@ -73,8 +118,8 @@ class ExpandResponse(BaseModel):
|
||||
|
||||
|
||||
# ─── ComfyUI helpers ──────────────────────────────────────────────────────────
|
||||
async def upload_to_comfy(data: bytes, filename: str) -> str:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
async def upload_to_comfy(data: bytes, filename: str) -> str:
|
||||
async with comfy_client(timeout=30) as client:
|
||||
r = await client.post(f"{COMFY}/upload/image",
|
||||
files={"image": (filename, data, "image/jpeg")},
|
||||
data={"overwrite": "true"})
|
||||
@@ -82,11 +127,11 @@ async def upload_to_comfy(data: bytes, filename: str) -> str:
|
||||
return r.json()["name"]
|
||||
|
||||
|
||||
def build_workflow(img_name: str, expanded: "ExpandedPrompt") -> dict:
|
||||
def build_workflow(img_name: str, expanded: "ExpandedPrompt", ckpt_name: str) -> dict:
|
||||
"""Build ComfyUI API workflow from an ExpandedPrompt result."""
|
||||
return {
|
||||
"1": {"class_type": "CheckpointLoaderSimple",
|
||||
"inputs": {"ckpt_name": "realvisxlV50_v50LightningBakedvae.safetensors"}},
|
||||
"1": {"class_type": "CheckpointLoaderSimple",
|
||||
"inputs": {"ckpt_name": ckpt_name}},
|
||||
"2": {"class_type": "LoadImage",
|
||||
"inputs": {"image": img_name, "upload": "image"}},
|
||||
"3": {"class_type": "CLIPTextEncode", # Positive prompt
|
||||
@@ -114,17 +159,23 @@ def build_workflow(img_name: str, expanded: "ExpandedPrompt") -> dict:
|
||||
}
|
||||
|
||||
|
||||
async def queue_prompt(workflow: dict) -> str:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
r = await client.post(f"{COMFY}/prompt",
|
||||
json={"prompt": workflow, "client_id": str(uuid.uuid4())})
|
||||
r.raise_for_status()
|
||||
return r.json()["prompt_id"]
|
||||
async def queue_prompt(workflow: dict) -> str:
|
||||
async with comfy_client(timeout=30) as client:
|
||||
r = await client.post(f"{COMFY}/prompt",
|
||||
json={"prompt": workflow, "client_id": str(uuid.uuid4())})
|
||||
if r.status_code >= 400:
|
||||
detail = r.text
|
||||
try:
|
||||
detail = json.dumps(r.json())
|
||||
except Exception:
|
||||
pass
|
||||
raise HTTPException(status_code=502, detail=f"ComfyUI rejected Dream Weaver workflow: {detail}")
|
||||
return r.json()["prompt_id"]
|
||||
|
||||
|
||||
async def poll_result(prompt_id: str, timeout: int = 300):
|
||||
start = time.time()
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async def poll_result(prompt_id: str, timeout: int = 300):
|
||||
start = time.time()
|
||||
async with comfy_client(timeout=10) as client:
|
||||
while time.time() - start < timeout:
|
||||
r = await client.get(f"{COMFY}/history/{prompt_id}")
|
||||
if r.status_code == 200:
|
||||
@@ -149,23 +200,36 @@ async def background_poll(job_id: str, prompt_id: str):
|
||||
|
||||
# ─── Endpoints ───────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
comfy_ok = False
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as c:
|
||||
r = await c.get(f"{COMFY}/system_stats")
|
||||
comfy_ok = r.status_code == 200
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"status": "ok",
|
||||
"comfyui": comfy_ok,
|
||||
"gpu": "4x NVIDIA L4 (96GB VRAM)",
|
||||
"model": "RealVisXL V5.0 Lightning",
|
||||
"llm_expansion": LLM_AVAILABLE,
|
||||
"version": "2.0.0"
|
||||
}
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
comfy_ok = False
|
||||
checkpoints: list[str] = []
|
||||
try:
|
||||
async with comfy_client(timeout=5) as c:
|
||||
r = await c.get(f"{COMFY}/system_stats")
|
||||
comfy_ok = r.status_code == 200
|
||||
except Exception:
|
||||
pass
|
||||
if comfy_ok:
|
||||
try:
|
||||
checkpoints = await list_comfy_checkpoints()
|
||||
except Exception:
|
||||
checkpoints = []
|
||||
return {
|
||||
"status": "ok",
|
||||
"comfyui": comfy_ok,
|
||||
"gpu": "4x NVIDIA L4 (96GB VRAM)",
|
||||
"model": "RealVisXL V5.0 Lightning",
|
||||
"comfyui_url": COMFY,
|
||||
"checkpoint_ready": bool(checkpoints),
|
||||
"checkpoint_count": len(checkpoints),
|
||||
"preferred_checkpoints": PREFERRED_CHECKPOINTS,
|
||||
"available_checkpoints": checkpoints[:12],
|
||||
"llm_expansion": LLM_AVAILABLE,
|
||||
"version": "2.0.0",
|
||||
"auth_required": GATEWAY_API_KEY is not None,
|
||||
"auth_scheme": "x-dream-weaver-api-key"
|
||||
}
|
||||
|
||||
|
||||
@app.get("/room-types")
|
||||
@@ -185,8 +249,9 @@ async def room_types():
|
||||
}
|
||||
|
||||
|
||||
@app.post("/dream-weaver/expand", response_model=ExpandResponse)
|
||||
async def expand_endpoint(req: ExpandRequest):
|
||||
@app.post("/dream-weaver/expand", response_model=ExpandResponse)
|
||||
async def expand_endpoint(req: ExpandRequest, request: Request):
|
||||
ensure_gateway_auth(request)
|
||||
"""
|
||||
Preview the LLM-generated prompt WITHOUT submitting to ComfyUI.
|
||||
Use this to let the user review/edit the prompt before generating.
|
||||
@@ -228,8 +293,9 @@ async def expand_endpoint(req: ExpandRequest):
|
||||
|
||||
|
||||
@app.post("/dream-weaver")
|
||||
async def dream_weaver(
|
||||
image: UploadFile = File(...),
|
||||
async def dream_weaver(
|
||||
request: Request,
|
||||
image: UploadFile = File(...),
|
||||
# ── Dynamic keyword mode (new) ──
|
||||
keywords: str = Form(default=""), # comma-separated: "blue marble, gold, renaissance"
|
||||
room_type: str = Form(default="living_room"),
|
||||
@@ -239,7 +305,7 @@ async def dream_weaver(
|
||||
custom_negative: str = Form(default=""),
|
||||
denoise: float = Form(default=0.0), # 0.0 = use LLM recommendation
|
||||
cfg_scale: float = Form(default=0.0), # 0.0 = use LLM recommendation
|
||||
):
|
||||
):
|
||||
"""
|
||||
Submit a room photo for AI redesign using dynamic keyword → LLM → ComfyUI pipeline.
|
||||
|
||||
@@ -249,7 +315,8 @@ async def dream_weaver(
|
||||
|
||||
Returns job_id for async polling.
|
||||
"""
|
||||
job_id = str(uuid.uuid4())
|
||||
ensure_gateway_auth(request)
|
||||
job_id = str(uuid.uuid4())
|
||||
jobs[job_id] = {"status": "uploading", "created": time.time()}
|
||||
|
||||
try:
|
||||
@@ -312,9 +379,11 @@ async def dream_weaver(
|
||||
"room_type": room_type,
|
||||
})
|
||||
|
||||
# Submit workflow
|
||||
wf = build_workflow(comfy_name, expanded)
|
||||
prompt_id = await queue_prompt(wf)
|
||||
# Submit workflow
|
||||
ckpt_name = await resolve_checkpoint()
|
||||
jobs[job_id]["checkpoint"] = ckpt_name
|
||||
wf = build_workflow(comfy_name, expanded, ckpt_name)
|
||||
prompt_id = await queue_prompt(wf)
|
||||
jobs[job_id].update({"status": "processing", "prompt_id": prompt_id})
|
||||
|
||||
# Start background polling
|
||||
@@ -339,9 +408,10 @@ async def dream_weaver(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/dream-weaver/status/{job_id}")
|
||||
async def status(job_id: str):
|
||||
job = jobs.get(job_id)
|
||||
@app.get("/dream-weaver/status/{job_id}")
|
||||
async def status(job_id: str, request: Request):
|
||||
ensure_gateway_auth(request)
|
||||
job = jobs.get(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
result = {k: v for k, v in job.items() if k != "output"}
|
||||
@@ -351,15 +421,16 @@ async def status(job_id: str):
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/dream-weaver/result/{job_id}")
|
||||
async def result(job_id: str):
|
||||
job = jobs.get(job_id)
|
||||
@app.get("/dream-weaver/result/{job_id}")
|
||||
async def result(job_id: str, request: Request):
|
||||
ensure_gateway_auth(request)
|
||||
job = jobs.get(job_id)
|
||||
if not job or job.get("status") != "done":
|
||||
raise HTTPException(status_code=404, detail="Result not ready")
|
||||
img = job["output"]
|
||||
url = (f"{COMFY}/view?filename={img['filename']}"
|
||||
f"&subfolder={img.get('subfolder','')}&type={img.get('type','output')}")
|
||||
async with httpx.AsyncClient(timeout=30) as c:
|
||||
async with comfy_client(timeout=30) as c:
|
||||
r = await c.get(url)
|
||||
return StreamingResponse(
|
||||
io.BytesIO(r.content),
|
||||
@@ -369,19 +440,21 @@ async def result(job_id: str):
|
||||
|
||||
|
||||
@app.post("/dream-weaver/sync")
|
||||
async def dream_weaver_sync(
|
||||
image: UploadFile = File(...),
|
||||
async def dream_weaver_sync(
|
||||
request: Request,
|
||||
image: UploadFile = File(...),
|
||||
keywords: str = Form(default=""),
|
||||
room_type: str = Form(default="living_room"),
|
||||
additional_notes: str = Form(default=""),
|
||||
custom_positive: str = Form(default=""),
|
||||
custom_negative: str = Form(default=""),
|
||||
):
|
||||
):
|
||||
"""
|
||||
Blocking version — waits up to 120s and returns image bytes directly.
|
||||
Use for testing. Prefer async /dream-weaver for production.
|
||||
"""
|
||||
data = await image.read()
|
||||
ensure_gateway_auth(request)
|
||||
data = await image.read()
|
||||
filename = f"sync_{uuid.uuid4().hex[:8]}_{image.filename or 'room.jpg'}"
|
||||
comfy_name = await upload_to_comfy(data, filename)
|
||||
|
||||
@@ -404,14 +477,15 @@ async def dream_weaver_sync(
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Provide keywords or custom_positive")
|
||||
|
||||
wf = build_workflow(comfy_name, expanded)
|
||||
ckpt_name = await resolve_checkpoint()
|
||||
wf = build_workflow(comfy_name, expanded, ckpt_name)
|
||||
prompt_id = await queue_prompt(wf)
|
||||
img, err = await poll_result(prompt_id, timeout=120)
|
||||
if err:
|
||||
raise HTTPException(status_code=500, detail=str(err))
|
||||
url = (f"{COMFY}/view?filename={img['filename']}"
|
||||
f"&subfolder={img.get('subfolder','')}&type={img.get('type','output')}")
|
||||
async with httpx.AsyncClient(timeout=30) as c:
|
||||
async with comfy_client(timeout=30) as c:
|
||||
r = await c.get(url)
|
||||
return StreamingResponse(io.BytesIO(r.content), media_type="image/png",
|
||||
headers={"X-Style": expanded.style_name,
|
||||
|
||||
49
comfy_engine/scripts/gateway_auth.py
Normal file
49
comfy_engine/scripts/gateway_auth.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Mapping
|
||||
|
||||
|
||||
_SUPPORTED_ENV_KEYS = (
|
||||
"DREAM_WEAVER_GATEWAY_API_KEY",
|
||||
"DREAM_WEAVER_API_KEY",
|
||||
)
|
||||
|
||||
|
||||
def load_gateway_api_key(env: Mapping[str, str] | None = None) -> str | None:
|
||||
values = env if env is not None else os.environ
|
||||
for key in _SUPPORTED_ENV_KEYS:
|
||||
raw = values.get(key)
|
||||
if raw is None:
|
||||
continue
|
||||
trimmed = raw.strip()
|
||||
if trimmed:
|
||||
return trimmed
|
||||
return None
|
||||
|
||||
|
||||
def extract_gateway_api_key(headers: Mapping[str, str]) -> str | None:
|
||||
for header_name in ("x-dream-weaver-api-key", "x-api-key"):
|
||||
value = headers.get(header_name)
|
||||
if value:
|
||||
trimmed = value.strip()
|
||||
if trimmed:
|
||||
return trimmed
|
||||
|
||||
authorization = headers.get("authorization", "")
|
||||
if authorization.lower().startswith("bearer "):
|
||||
token = authorization[7:].strip()
|
||||
if token:
|
||||
return token
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def is_gateway_request_authorized(
|
||||
headers: Mapping[str, str],
|
||||
required_api_key: str | None,
|
||||
) -> bool:
|
||||
if required_api_key is None:
|
||||
return True
|
||||
presented = extract_gateway_api_key(headers)
|
||||
return presented == required_api_key
|
||||
@@ -4,38 +4,116 @@ from pathlib import Path
|
||||
from typing import Optional, List
|
||||
import httpx
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, UploadFile, File, HTTPException, Form, BackgroundTasks
|
||||
from fastapi import FastAPI, UploadFile, File, HTTPException, Form, Request
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).parent / "scripts"
|
||||
ROOT_DIR = Path(__file__).resolve().parent
|
||||
SCRIPTS_DIR = ROOT_DIR / "scripts"
|
||||
if not SCRIPTS_DIR.exists():
|
||||
SCRIPTS_DIR = ROOT_DIR / "comfy_engine" / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
try:
|
||||
from prompt_expander import expand_prompt, expand_prompt_simple, ROOM_CONTEXTS, ExpandedPrompt
|
||||
from gateway_auth import load_gateway_api_key, is_gateway_request_authorized
|
||||
LLM_AVAILABLE = True
|
||||
except ImportError:
|
||||
LLM_AVAILABLE = False
|
||||
logging.warning("prompt_expander not found — LLM expansion disabled")
|
||||
|
||||
class ExpandedPrompt(BaseModel):
|
||||
style_name: str
|
||||
positive_prompt: str
|
||||
negative_prompt: str
|
||||
steps: int = 28
|
||||
cfg: float = 7.0
|
||||
denoise: float = 0.72
|
||||
|
||||
ROOM_CONTEXTS = {}
|
||||
|
||||
def expand_prompt(keywords: List[str], room_type: str) -> ExpandedPrompt:
|
||||
pretty_room = room_type.replace("_", " ").strip() or "living room"
|
||||
pretty_keywords = ", ".join(keywords) if keywords else "modern, photorealistic"
|
||||
return ExpandedPrompt(
|
||||
style_name="Fallback Prompt Expansion",
|
||||
positive_prompt=(
|
||||
f"photorealistic premium {pretty_room} interior design, {pretty_keywords}, "
|
||||
"natural lighting, realistic materials, architect-grade composition"
|
||||
),
|
||||
negative_prompt=(
|
||||
"worst quality, low quality, blurry, distorted perspective, "
|
||||
"people, watermark, text, duplicate objects"
|
||||
),
|
||||
)
|
||||
|
||||
expand_prompt_simple = expand_prompt
|
||||
from gateway_auth import load_gateway_api_key, is_gateway_request_authorized
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
logger = logging.getLogger("DreamWeaverGateway")
|
||||
COMFY = "http://127.0.0.1:8188"
|
||||
COMFY = (os.environ.get("COMFYUI_URL") or os.environ.get("COMFY_URL") or "http://127.0.0.1:8188").rstrip("/")
|
||||
COMFY_TLS_VERIFY = os.environ.get("COMFYUI_TLS_VERIFY", "true").strip().lower() not in {"0", "false", "no", "off"}
|
||||
GATEWAY_API_KEY = load_gateway_api_key()
|
||||
PREFERRED_CHECKPOINTS = [
|
||||
"realvisxlV50_v50LightningBakedvae.safetensors",
|
||||
"realvisxlV50Lightning_v50Lightning.safetensors",
|
||||
]
|
||||
|
||||
app = FastAPI(title="Dream Weaver API v2", version="2.0.0")
|
||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
||||
jobs: dict = {}
|
||||
|
||||
def comfy_client(timeout: float = 30) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(timeout=timeout, verify=COMFY_TLS_VERIFY, follow_redirects=True)
|
||||
|
||||
async def list_comfy_checkpoints() -> list[str]:
|
||||
async with comfy_client(timeout=10) as client:
|
||||
response = await client.get(f"{COMFY}/models/checkpoints")
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if isinstance(payload, list):
|
||||
return [item for item in payload if isinstance(item, str)]
|
||||
return []
|
||||
|
||||
async def resolve_checkpoint() -> str:
|
||||
checkpoints = await list_comfy_checkpoints()
|
||||
if not checkpoints:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=(
|
||||
"ComfyUI is online but has no checkpoint models installed. "
|
||||
"Hydrate RealVisXL into ComfyUI/models/checkpoints before generating."
|
||||
),
|
||||
)
|
||||
lower_lookup = {item.lower(): item for item in checkpoints}
|
||||
for preferred in PREFERRED_CHECKPOINTS:
|
||||
match = lower_lookup.get(preferred.lower())
|
||||
if match:
|
||||
return match
|
||||
return checkpoints[0]
|
||||
|
||||
def gateway_urls(job_id: str) -> dict:
|
||||
return {
|
||||
"poll_url": f"/dream-weaver/status/{job_id}",
|
||||
"result_url": f"/dream-weaver/result/{job_id}",
|
||||
}
|
||||
|
||||
def ensure_gateway_auth(request: Request) -> None:
|
||||
if is_gateway_request_authorized(request.headers, GATEWAY_API_KEY):
|
||||
return
|
||||
raise HTTPException(status_code=401, detail="Dream Weaver gateway API key is required or invalid.")
|
||||
|
||||
async def upload_to_comfy(data: bytes, filename: str) -> str:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
async with comfy_client(timeout=30) as client:
|
||||
r = await client.post(f"{COMFY}/upload/image", files={"image": (filename, data, "image/jpeg")}, data={"overwrite": "true"})
|
||||
r.raise_for_status()
|
||||
return r.json()["name"]
|
||||
|
||||
def build_workflow(img_name: str, expanded: "ExpandedPrompt") -> dict:
|
||||
def build_workflow(img_name: str, expanded: "ExpandedPrompt", ckpt_name: str) -> dict:
|
||||
return {
|
||||
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "realvisxlV50_v50LightningBakedvae.safetensors"}},
|
||||
"1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": ckpt_name}},
|
||||
"2": {"class_type": "LoadImage", "inputs": {"image": img_name, "upload": "image"}},
|
||||
"3": {"class_type": "CLIPTextEncode", "inputs": {"text": expanded.positive_prompt, "clip": ["1", 1]}},
|
||||
"4": {"class_type": "CLIPTextEncode", "inputs": {"text": expanded.negative_prompt, "clip": ["1", 1]}},
|
||||
@@ -46,14 +124,20 @@ def build_workflow(img_name: str, expanded: "ExpandedPrompt") -> dict:
|
||||
}
|
||||
|
||||
async def queue_prompt(workflow: dict) -> str:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
async with comfy_client(timeout=30) as client:
|
||||
r = await client.post(f"{COMFY}/prompt", json={"prompt": workflow, "client_id": str(uuid.uuid4())})
|
||||
r.raise_for_status()
|
||||
if r.status_code >= 400:
|
||||
detail = r.text
|
||||
try:
|
||||
detail = json.dumps(r.json())
|
||||
except Exception:
|
||||
pass
|
||||
raise HTTPException(status_code=502, detail=f"ComfyUI rejected Dream Weaver workflow: {detail}")
|
||||
return r.json()["prompt_id"]
|
||||
|
||||
async def poll_result(prompt_id: str, timeout: int = 300):
|
||||
start = time.time()
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with comfy_client(timeout=10) as client:
|
||||
while time.time() - start < timeout:
|
||||
r = await client.get(f"{COMFY}/history/{prompt_id}")
|
||||
if r.status_code == 200:
|
||||
@@ -70,29 +154,89 @@ async def background_poll(job_id: str, prompt_id: str):
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok", "comfyui": True, "llm_expansion": LLM_AVAILABLE, "version": "2.0.0"}
|
||||
checkpoints: list[str] = []
|
||||
try:
|
||||
checkpoints = await list_comfy_checkpoints()
|
||||
except Exception:
|
||||
checkpoints = []
|
||||
return {
|
||||
"status": "ok",
|
||||
"comfyui": True,
|
||||
"comfyui_url": COMFY,
|
||||
"checkpoint_ready": bool(checkpoints),
|
||||
"checkpoint_count": len(checkpoints),
|
||||
"preferred_checkpoints": PREFERRED_CHECKPOINTS,
|
||||
"available_checkpoints": checkpoints[:12],
|
||||
"llm_expansion": LLM_AVAILABLE,
|
||||
"version": "2.0.0",
|
||||
"auth_required": GATEWAY_API_KEY is not None,
|
||||
"auth_scheme": "x-dream-weaver-api-key"
|
||||
}
|
||||
|
||||
@app.get("/dream-weaver/status/{job_id}")
|
||||
async def status(job_id: str):
|
||||
async def status(job_id: str, request: Request):
|
||||
ensure_gateway_auth(request)
|
||||
job = jobs.get(job_id)
|
||||
if not job: raise HTTPException(status_code=404, detail="Job not found")
|
||||
res = {k: v for k, v in job.items() if k != "output"}
|
||||
res["ready"] = job.get("status") == "done"
|
||||
if res["ready"]:
|
||||
res.update(gateway_urls(job_id))
|
||||
return res
|
||||
|
||||
@app.post("/dream-weaver")
|
||||
async def dream_weaver(image: UploadFile = File(...), keywords: str = Form(default=""), room_type: str = Form(default="living_room")):
|
||||
async def dream_weaver(
|
||||
request: Request,
|
||||
image: UploadFile = File(...),
|
||||
keywords: str = Form(default=""),
|
||||
room_type: str = Form(default="living_room")
|
||||
):
|
||||
ensure_gateway_auth(request)
|
||||
job_id = str(uuid.uuid4())
|
||||
jobs[job_id] = {"status": "uploading", "created": time.time()}
|
||||
data = await image.read()
|
||||
comfy_name = await upload_to_comfy(data, f"dw_{job_id[:8]}.jpg")
|
||||
kw_list = [k.strip() for k in keywords.split(",") if k.strip()]
|
||||
expanded = await asyncio.to_thread(expand_prompt, keywords=kw_list, room_type=room_type)
|
||||
wf = build_workflow(comfy_name, expanded)
|
||||
ckpt_name = await resolve_checkpoint()
|
||||
jobs[job_id]["checkpoint"] = ckpt_name
|
||||
wf = build_workflow(comfy_name, expanded, ckpt_name)
|
||||
prompt_id = await queue_prompt(wf)
|
||||
jobs[job_id].update({"status": "processing", "prompt_id": prompt_id})
|
||||
asyncio.create_task(background_poll(job_id, prompt_id))
|
||||
return {"job_id": job_id, "status": "processing"}
|
||||
return {
|
||||
"job_id": job_id,
|
||||
"status": "processing",
|
||||
**gateway_urls(job_id),
|
||||
}
|
||||
|
||||
@app.get("/dream-weaver/result/{job_id}")
|
||||
async def result(job_id: str, request: Request):
|
||||
ensure_gateway_auth(request)
|
||||
job = jobs.get(job_id)
|
||||
if not job or job.get("status") != "done":
|
||||
raise HTTPException(status_code=404, detail="Result not ready")
|
||||
|
||||
img = job.get("output")
|
||||
if not img:
|
||||
raise HTTPException(status_code=404, detail="Result not ready")
|
||||
|
||||
async with comfy_client(timeout=30) as client:
|
||||
response = await client.get(
|
||||
f"{COMFY}/view",
|
||||
params={
|
||||
"filename": img["filename"],
|
||||
"subfolder": img.get("subfolder", ""),
|
||||
"type": img.get("type", "output"),
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
return StreamingResponse(
|
||||
io.BytesIO(response.content),
|
||||
media_type="image/png",
|
||||
headers={"Content-Disposition": f"attachment; filename=dreamweaver_{job_id[:8]}.png"},
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8082, log_level="info")
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
enum AppSection: String, CaseIterable, Hashable, Identifiable {
|
||||
var id: String { rawValue }
|
||||
case dashboard = "Dashboard"
|
||||
case communications = "Communications"
|
||||
case calendar = "Calendar"
|
||||
case oracle = "Oracle"
|
||||
case sentinel = "Sentinel"
|
||||
case inventory = "Inventory"
|
||||
case settings = "Settings"
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .dashboard: return "square.grid.2x2"
|
||||
case .communications: return "phone.connection"
|
||||
case .calendar: return "calendar.badge.clock"
|
||||
case .oracle: return "message.and.waveform"
|
||||
case .sentinel: return "person.crop.rectangle"
|
||||
case .inventory: return "shippingbox"
|
||||
case .settings: return "gearshape"
|
||||
}
|
||||
}
|
||||
|
||||
var accentColor: Color {
|
||||
switch self {
|
||||
case .dashboard: return VelocityTheme.accent
|
||||
case .communications: return Color(red: 0.19, green: 0.84, blue: 0.63)
|
||||
case .calendar: return Color(red: 0.96, green: 0.67, blue: 0.16)
|
||||
case .oracle: return Color(red: 0.13, green: 0.83, blue: 0.93) // cyan
|
||||
case .sentinel: return Color(red: 0.60, green: 0.57, blue: 0.99) // indigo
|
||||
case .inventory: return VelocityTheme.warning
|
||||
case .settings: return VelocityTheme.mutedFg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ContentView: View {
|
||||
@State private var selectedSection: AppSection? = .dashboard
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView(columnVisibility: .constant(.all)) {
|
||||
sidebarContent
|
||||
} detail: {
|
||||
detailContent
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
}
|
||||
|
||||
// MARK: – Sidebar
|
||||
private var sidebarContent: some View {
|
||||
ZStack {
|
||||
VelocityTheme.sidebarBg.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// App title
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 9)
|
||||
.fill(VelocityTheme.accent.opacity(0.18))
|
||||
.frame(width: 34, height: 34)
|
||||
Image(systemName: "bolt.fill")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("Velocity")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Project Velocity · v1.1")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
Divider()
|
||||
.background(VelocityTheme.borderSubtle)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
// Nav items
|
||||
VStack(spacing: 2) {
|
||||
ForEach(AppSection.allCases) { section in
|
||||
SidebarRow(section: section,
|
||||
isSelected: selectedSection == section)
|
||||
.onTapGesture { selectedSection = section }
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
|
||||
Spacer()
|
||||
|
||||
// User footer
|
||||
Divider()
|
||||
.background(VelocityTheme.borderSubtle)
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(VelocityTheme.accent)
|
||||
.frame(width: 32, height: 32)
|
||||
Text("AF")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Ahmed Al-Farsi")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Sales Director")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
.navigationTitle("")
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
}
|
||||
|
||||
// MARK: – Detail
|
||||
private var detailContent: some View {
|
||||
ZStack {
|
||||
VelocityTheme.background.ignoresSafeArea()
|
||||
|
||||
Group {
|
||||
switch selectedSection {
|
||||
case .dashboard: DashboardView()
|
||||
case .communications: CommunicationsView()
|
||||
case .calendar: CalendarView()
|
||||
case .oracle: OracleView()
|
||||
case .sentinel: SentinelView()
|
||||
case .inventory: InventoryView()
|
||||
case .settings: SettingsView()
|
||||
case .none: DashboardView()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Sidebar Row
|
||||
private struct SidebarRow: View {
|
||||
let section: AppSection
|
||||
let isSelected: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 11) {
|
||||
Image(systemName: section.systemImage)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(isSelected ? section.accentColor : VelocityTheme.mutedFg)
|
||||
.frame(width: 20)
|
||||
|
||||
Text(section.rawValue)
|
||||
.font(.system(size: 14, weight: isSelected ? .semibold : .regular))
|
||||
.foregroundStyle(isSelected ? VelocityTheme.foreground : VelocityTheme.mutedFg)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 9)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(isSelected ? section.accentColor.opacity(0.12) : .clear)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(isSelected ? section.accentColor.opacity(0.25) : .clear, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum AppConfig {
|
||||
private static func value(for key: String) -> String? {
|
||||
guard let raw = Bundle.main.infoDictionary?[key] as? String else {
|
||||
return nil
|
||||
}
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty || trimmed == "$(\(key))" {
|
||||
return nil
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
static let baseURL: String = value(for: "BASE_URL") ?? "http://54.91.19.60:8082"
|
||||
static let apiEmail: String? = value(for: "API_EMAIL")
|
||||
static let apiPassword: String? = value(for: "API_PASSWORD")
|
||||
static let apiBearerToken: String? = value(for: "API_BEARER_TOKEN")
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
|
||||
struct SunPosition {
|
||||
let azimuth: Double // 0...360, degrees clockwise from true north
|
||||
let elevation: Double // -90...90 degrees above horizon
|
||||
}
|
||||
|
||||
enum SunMath {
|
||||
static func calculateSunPosition(date: Date, coordinate: CLLocationCoordinate2D) -> SunPosition {
|
||||
let timezone = TimeZone.current
|
||||
let localOffsetHours = Double(timezone.secondsFromGMT(for: date)) / 3600.0
|
||||
let julianDay = date.julianDay
|
||||
|
||||
let n = julianDay - 2_451_545.0
|
||||
let meanLongitude = normalizeDegrees(280.46 + 0.985_647_4 * n)
|
||||
let meanAnomaly = normalizeDegrees(357.528 + 0.985_600_3 * n)
|
||||
|
||||
let lambda = meanLongitude
|
||||
+ 1.915 * sin(meanAnomaly.radians)
|
||||
+ 0.020 * sin((2.0 * meanAnomaly).radians)
|
||||
let obliquity = 23.439 - 0.000_000_4 * n
|
||||
|
||||
let rightAscension = atan2(
|
||||
cos(obliquity.radians) * sin(lambda.radians),
|
||||
cos(lambda.radians)
|
||||
).degrees
|
||||
let declination = asin(sin(obliquity.radians) * sin(lambda.radians)).degrees
|
||||
|
||||
let utcHours = date.utcHours
|
||||
let lst = normalizeDegrees(100.46 + 0.985_647 * n + coordinate.longitude + 15.0 * utcHours + localOffsetHours)
|
||||
let hourAngle = normalizeDegrees(lst - rightAscension)
|
||||
let signedHourAngle = hourAngle > 180.0 ? hourAngle - 360.0 : hourAngle
|
||||
|
||||
let latitude = coordinate.latitude.radians
|
||||
let declinationRad = declination.radians
|
||||
let hourAngleRad = signedHourAngle.radians
|
||||
|
||||
let elevation = asin(
|
||||
sin(latitude) * sin(declinationRad)
|
||||
+ cos(latitude) * cos(declinationRad) * cos(hourAngleRad)
|
||||
).degrees
|
||||
|
||||
let azimuth = normalizeDegrees(
|
||||
atan2(
|
||||
-sin(hourAngleRad),
|
||||
tan(declinationRad) * cos(latitude) - sin(latitude) * cos(hourAngleRad)
|
||||
).degrees
|
||||
)
|
||||
|
||||
return SunPosition(azimuth: azimuth, elevation: elevation)
|
||||
}
|
||||
|
||||
static func sunPathSamples(for date: Date, coordinate: CLLocationCoordinate2D) -> [Date: SunPosition] {
|
||||
let calendar = Calendar.current
|
||||
let sampleHours = [8, 10, 12, 14, 16]
|
||||
var output: [Date: SunPosition] = [:]
|
||||
|
||||
for hour in sampleHours {
|
||||
if let sampleDate = calendar.date(bySettingHour: hour, minute: 0, second: 0, of: date) {
|
||||
output[sampleDate] = calculateSunPosition(date: sampleDate, coordinate: coordinate)
|
||||
}
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
private static func normalizeDegrees(_ value: Double) -> Double {
|
||||
let reduced = value.truncatingRemainder(dividingBy: 360.0)
|
||||
return reduced >= 0 ? reduced : reduced + 360.0
|
||||
}
|
||||
}
|
||||
|
||||
private extension Date {
|
||||
var utcHours: Double {
|
||||
let calendar = Calendar(identifier: .gregorian)
|
||||
let comps = calendar.dateComponents(in: TimeZone(secondsFromGMT: 0)!, from: self)
|
||||
let hours = Double(comps.hour ?? 0)
|
||||
let minutes = Double(comps.minute ?? 0)
|
||||
let seconds = Double(comps.second ?? 0)
|
||||
return hours + minutes / 60.0 + seconds / 3600.0
|
||||
}
|
||||
|
||||
var julianDay: Double {
|
||||
let interval = timeIntervalSince1970
|
||||
return (interval / 86_400.0) + 2_440_587.5
|
||||
}
|
||||
}
|
||||
|
||||
private extension Double {
|
||||
var radians: Double { self * .pi / 180.0 }
|
||||
var degrees: Double { self * 180.0 / .pi }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user