Compare commits

...

9 Commits

Author SHA1 Message Date
13f0cf8abe merge upstream 2026-05-03 18:32:14 +05:30
eeb684b46c feat: Ipad app production readiness, Colony orchestration, Social posting (#44)
#38 Ipad app production readiness, Colony orchestration, Social posting

Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local>
Reviewed-on: sagnik/Project_Velocity#44
2026-05-03 18:30:38 +05:30
acfc602157 merge upstream 2026-04-29 12:37:35 +05:30
Sagnik
59d398abc3 fix: restore velocity backend login 2026-04-28 14:02:18 +05:30
Sagnik
3623bacbac feat: Whatsapp Integration 2026-04-28 13:41:14 +05:30
4de2266f9d merge upstream 2026-04-28 11:35:57 +05:30
7ee51543d9 Merge Conflicts (#41)
Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local>
Reviewed-on: sagnik/Project_Velocity#41
2026-04-28 11:32:56 +05:30
Sayan Datta
34e226a36e Merge Conflicts 2026-04-28 11:24:05 +05:30
Sayan Datta
fefe8373ec feat: Ipad app features and Dream Weaver for Velocity WebOS 2026-04-28 10:59:07 +05:30
228 changed files with 47514 additions and 89581 deletions

View File

@@ -0,0 +1,265 @@
# Velocity Comms Integration — Handoff Document
## 1. Architecture Recommendation
### Goal
Add a native **Conversations** module to Project Velocity so brokers/agents can manage WhatsApp (and future SMS/call) threads without leaving the WebOS.
### Design Philosophy
- **Native Velocity UI**: Dark glass panels, compact density, blue accent, no iframe embeds.
- **Provider-agnostic backend**: Abstract `CommsProvider` class with adapter pattern.
- **CRM-first**: Every thread attempts to link to `crm_people` by `primary_phone`. Unresolved numbers are surfaced for manual linking.
- **Mock-first development**: The module renders fully without real credentials.
### Provider Comparison
| Provider | Best For | Velocity Fit | 72-Hour Viability |
|----------|----------|--------------|-------------------|
| **Chatwoot** | Full support suite (email, SMS, WA) | Too heavy to embed; good UX reference | Low — would require stripping UI |
| **WAHA** | Lightweight WhatsApp Web gateway | Good adapter candidate | High — simple REST, easy webhooks |
| **Evolution API** | Modern WA gateway with groups, status, typing | Best adapter candidate | **High** — active community, clean webhooks |
| **Meta Cloud API** | Official WABA; template-based outbound | Required for production scale at large builders | Medium — needs Meta Business verification |
**Recommended 72-hour route:**
1. **Day 1**: Merge schema + backend routes + mock provider. Frontend compiles with mock data.
2. **Day 2**: Connect Evolution API or WAHA in a staging environment. Test inbound webhook → thread creation.
3. **Day 3**: CRM linking, settings UI, call-log upload placeholder, and smoke tests.
For production, plan a **dual-provider** setup:
- **Evolution/WAHA** for quick conversational messaging (no Meta approval needed).
- **Meta Cloud API** for template-based broadcast/re-engagement once Business Manager is verified.
---
## 2. Exact Files Created
```
app/src/types/commsTypes.ts
app/src/lib/commsApi.ts
app/src/components/modules/Comms.tsx
backend/db/schema_comms.sql
backend/services/comms_provider.py
backend/services/comms_waha_provider.py
backend/services/comms_evolution_provider.py
backend/services/comms_ingest.py
backend/api/routes_comms.py
COMMS_INTEGRATION_HANDOFF.md
```
---
## 3. Patch Instructions for Existing Files
### A. `app/src/types/index.ts`
Add `'comms'` to the `ModuleId` union:
```typescript
export type ModuleId = 'dashboard' | 'oracle' | 'sentinel' | 'inventory' | 'settings' | 'catalyst' | 'admin' | 'crm' | 'comms';
```
### B. `app/src/App.tsx`
1. Import the new component:
```typescript
import { Comms } from '@/components/modules/Comms';
```
2. Insert the route into `MODULE_ROUTES` **just before** `settings`:
```typescript
{ id: 'comms', path: '/comms', title: 'Conversations', component: Comms },
```
### C. `app/src/components/layout/Sidebar.tsx`
1. Import a new icon:
```typescript
import { MessageCircle } from 'lucide-react';
```
2. Add to `NAV_ICONS`:
```typescript
const NAV_ICONS: Record<string, LucideIcon> = {
'/dashboard': LayoutGrid,
'/oracle': MessageSquarePlus,
'/sentinel': ScanFace,
'/inventory': Building2,
'/catalyst': Megaphone,
'/comms': MessageCircle, // ← NEW
'/settings': Sliders,
'/admin': Shield,
'/crm': Users,
};
```
### D. `backend/main.py`
1. Import the router near the other imports:
```python
from backend.api.routes_comms import router as comms_router
```
2. Include it after the other routers:
```python
app.include_router(comms_router, prefix="/api/comms", tags=["Comms"])
```
---
## 4. Environment Variables
Add these to your `.env` or systemd environment:
```bash
# Provider selection: mock | waha | evolution | meta_cloud
COMMS_PROVIDER=mock
# Provider connectivity
COMMS_PROVIDER_BASE_URL=
COMMS_PROVIDER_API_KEY=
COMMS_INSTANCE_ID=default
# Webhook security
COMMS_WEBHOOK_SECRET=
# Phone normalization
COMMS_DEFAULT_COUNTRY_CODE=91
# Media storage
COMMS_MEDIA_STORAGE_DIR=/opt/dlami/nvme/assets/comms
# Transcription (none | openai | local)
COMMS_TRANSCRIPTION_PROVIDER=none
```
**No secrets are hardcoded in source.**
---
## 5. Database Migration
Run the SQL file against your Postgres database:
```bash
psql $DATABASE_URL -f backend/db/schema_comms.sql
```
Tables created:
- `comms_threads` — conversation headers with CRM link
- `comms_messages` — individual messages (inbound/outbound/system)
- `comms_call_logs` — call records with optional transcript
- `comms_settings` — key-value config store
---
## 6. API Routes
| Method | Path | Purpose |
|--------|------|---------|
| GET | `/api/comms/threads` | List threads (search, status, pagination) |
| GET | `/api/comms/threads/{id}` | Get single thread with CRM enrichment |
| GET | `/api/comms/threads/{id}/messages` | Chronological messages |
| POST | `/api/comms/threads/{id}/messages` | Send outbound message via provider |
| POST | `/api/comms/threads/{id}/link-person` | Link thread to `crm_people.id` |
| POST | `/api/comms/threads/{id}/notes` | Add system note |
| POST | `/api/comms/threads/{id}/tasks` | Add system task |
| POST | `/api/comms/webhooks/{provider}` | Public webhook endpoint |
| GET | `/api/comms/settings` | Get comms configuration |
| PATCH | `/api/comms/settings` | Update configuration |
| POST | `/api/comms/provider/test` | Test provider connectivity |
| POST | `/api/comms/recordings/transcribe` | Queue transcription job |
---
## 7. Frontend Route Changes
- New sidebar item: **Conversations** (icon: `MessageCircle`)
- Position: directly above **Settings**
- Route: `/comms`
- Component: `Comms.tsx` with three-pane layout (Inbox | Chat | CRM Rail)
---
## 8. Settings Changes
A new **Communications** subsection should be added inside your existing Settings module (or as a standalone card). Fields:
| Field | Type | Description |
|-------|------|-------------|
| Provider | select | mock / waha / evolution / meta_cloud |
| Provider Base URL | text | e.g. `http://localhost:3000` |
| API Key | password | masked after save |
| Instance ID | text | WA/Evolution session name |
| Phone Number ID | text | Meta Cloud API only |
| Webhook Callback URL | text | Auto-populated or custom |
| Webhook Secret | password | Sets `webhook_secret_set` flag |
| Default Assignment User | select | User dropdown from `/api/auth/users` |
| Auto-link by Phone | toggle | Match `crm_people.primary_phone` automatically |
| Create CRM Interaction on Inbound | toggle | Write to `intel_interactions` if table exists |
| Default Country Code | text | e.g. `91` for India |
| Transcription Provider | select | none / openai / local |
| Connection Test | button | Calls `POST /api/comms/provider/test` |
---
## 9. Smoke Test Steps
1. **DB**: Run `schema_comms.sql`. Verify tables exist.
2. **Backend**: Start FastAPI. Confirm `/health` returns `db_pool: connected`.
3. **Backend**: `curl -X POST http://localhost:8000/api/comms/provider/test` → should return mock success.
4. **Frontend**: Load Velocity. Sidebar should show **Conversations**.
5. **Frontend**: Click Conversations. Mock mode should render 3 threads and messages.
6. **Frontend**: Send a message in mock thread. Optimistic update → mock delivery checkmark.
7. **Backend**: Post sample webhook:
```bash
curl -X POST http://localhost:8000/api/comms/webhooks/evolution -H "Content-Type: application/json" -d '{"event":"messages.upsert","instance":"default","data":{"key":{"remoteJid":"919876543210@s.whatsapp.net","fromMe":false,"id":"test-1"},"message":{"conversation":"Hello from webhook"},"messageTimestamp":1710000000}}'
```
8. **Backend**: Verify thread + message inserted. Check `comms_threads` for new row.
9. **Frontend**: Refresh inbox. New thread should appear.
10. **CRM Link**: Click "Link to Contact" (or call `POST /api/comms/threads/{id}/link-person`) and verify `person_id` is set.
---
## 10. Known Limitations
- **Call recording via WhatsApp API**: Neither WAHA nor Evolution supports native WhatsApp call recording. Call logs are designed for **external telephony intake** (manual upload or webhook from a PBX/VoIP system). Recording file + transcript workflow is scaffolded but needs a real transcription provider (OpenAI Whisper, AWS Transcribe, or faster-whisper) wired in.
- **Media downloads**: `get_media()` is stubbed for WAHA/Evolution. Production needs signed URL handling or local file download.
- **Meta Cloud API adapter**: Not yet implemented. Add `comms_meta_provider.py` when Meta Business verification is complete.
- **Template messages**: Only placeholder methods exist. Template approval flow (Meta) or local template storage must be built for outbound campaigns.
- **Webhook auth**: Currently accepts any payload. Add HMAC/signature verification per provider before production.
- **Rate limiting**: Not implemented. Add FastAPI rate-limit middleware on `/api/comms/webhooks/{provider}`.
- **phonenumbers library**: `comms_ingest.py` gracefully degrades to regex if `phonenumbers` is not installed. Install it for robust E.164 normalization:
```bash
pip install phonenumbers
```
---
## 11. What Still Needs Real Credentials
| Item | What You Need |
|------|---------------|
| **Evolution API** | A running Evolution instance (Docker), API key, and a paired WhatsApp number. |
| **WAHA** | A running WAHA container, session QR-scan, and API key. |
| **Meta Cloud API** | Meta Business Manager, verified business, WhatsApp Business Account, permanent access token, phone number ID. |
| **Transcription** | OpenAI API key (for Whisper) or local faster-whisper model path. |
| **CRM enrichment** | Ensure `crm_people` table exists with `primary_phone` indexed. |
---
## 12. What to Verify Before Production
- [ ] Webhook endpoint is exposed via HTTPS (ngrok/cloudflare tunnel for local dev).
- [ ] `COMMS_WEBHOOK_SECRET` is set and signature verification is enabled in `routes_comms.py`.
- [ ] Database has indexes on `comms_threads(phone_e164)` and `comms_messages(thread_id, created_at)`.
- [ ] `crm_people.primary_phone` is normalized to E.164 before comms matching.
- [ ] Media storage directory exists and is writable (`COMMS_MEDIA_STORAGE_DIR`).
- [ ] Outbound message queue / retry logic is added (currently synchronous).
- [ ] GDPR/opt-out handling is implemented if targeting EU markets.
- [ ] Backup strategy for `comms_messages` (contains legal conversation records).
---
## 13. Next Iteration Ideas
- **Bulk broadcast**: Template-based outbound to filtered CRM segments.
- **AI reply suggestions**: Integrate Oracle / local LLM to draft replies based on CRM context.
- **Voice notes**: Upload `.ogg` audio, transcribe, store transcript as message.
- **Read receipts**: Poll provider for delivery/read status and update `comms_messages`.
- **Assignment rules**: Round-robin or load-based auto-assignment to agents.
---
*Document generated for Project Velocity v1.1 — Comms Module Integration*

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,98 @@
I audited iOS/velocity-ipad/velocity for mock/demo/static/fallback data paths. Good news: I did not find hard-coded fake CRM people, fake properties, fake opportunities, fake calendar events, or fake comms threads being rendered as normal production data. The app is mostly live-backed.
That said, there are still several hard-coded or locally synthesized data paths you should decide whether to move fully behind backend/database contracts.
Production-Risk Mock Or Synthetic Data
SimulatorSunOverlayView.swift (line 8)
Uses fake simulator-only location: San Francisco 37.7749, -122.4194.
Uses mock heading: 0.
Wrapped in #if targetEnvironment(simulator), so it should not run on physical iPad, but it is still mock data in app code.
InventoryView.swift (line 941)
If Building.usdz / Building.scn fails to load, Dollhouse falls back to a procedural synthetic building.
The fallback creates hard-coded rooms, walls, colors, and dimensions at InventoryView.swift (line 975).
For production, this should probably fail closed or fetch a real model reference from backend inventory metadata.
Building.usda (line 1) and Building.usdz
Bundled local 3D building asset with hard-coded cube geometry: Podium, TowerA, TowerB, AmenityDeck, Courtyard.
This is not backend/database-backed property inventory. It is a static app asset.
Frontend-Derived Fallback Data
VelocityAPIClient.swift (line 1538)
VelocityClient360DTO.minimal(from:) fabricates a Client 360 snapshot from a contact when richer Client 360 data is unavailable.
It creates local QD overview fields, empty opportunities/interactions/tasks/interests, and note "Derived from the CRM client-data endpoint."
VelocityAPIClient.swift (line 2725)
If Client 360 decode fails with invalidResponse, the app fetches contacts and builds the minimal local snapshot instead of failing.
VelocityAPIClient.swift (line 1905)
If backend gives QD scores but no recommended actions, the app generates: Review {scoreType} score at {displayScore}.
This is local advisory text, not backend intelligence.
AppStore.swift (line 873)
Dashboard metrics fall back to locally computed canonicalDashboardMetrics(...) if /api/dashboard/metrics fails.
The app computes lead count, whale count, property count, today calendar count, pending insights, etc. locally at AppStore.swift (line 902).
AppStore.swift (line 857)
If contact fetch returns 404, app reuses cached contacts instead of treating backend as source-of-truth unavailable.
AppStore.swift (line 869)
Several failed backend calls silently fall back to empty arrays: kanban, opportunities, properties.
This can make missing backend data look like “zero production data.”
Local Offline Data That Becomes Temporary UI Truth
10. AppStore.swift (line 607)
Offline calendar create generates local IDs like local-{UUID} and local createdAt.
The event is merged into UI before backend confirmation.
AppStore.swift (line 436)
If task mutation happens offline and the task cannot be resolved, app fabricates a local task title: "Queued CRM task" and default priority "normal".
OfflineReplayStore.swift (line 16)
App stores offline replay mutations in local Core Data OfflineReplay.sqlite.
This is valid offline architecture, but if you require strict backend-only truth, this should be treated as a write queue only and clearly marked as “pending sync.”
Hard-Coded Business Vocabularies
13. ClientsView.swift (line 35)
Hard-coded lead statuses: new, contacted, qualified, site_visit_scheduled, etc.
ClientsView.swift (line 48)
Hard-coded urgency values: low, medium, high, critical.
ClientsView.swift (line 49)
Hard-coded buyer types: end_user, hni_end_user, nri_investor, family_office, etc.
ClientsView.swift (line 59)
Hard-coded task priorities: low, normal, high, urgent.
OracleView.swift (line 1001)
Hard-coded canonical lead stages.
OracleView.swift (line 1016)
Hard-coded opportunity stages: prospect, qualified, proposal, site_visit, etc.
ImportsView.swift (line 26)
Hard-coded duplicate policies: create_new, update_existing, skip_duplicate.
InventoryView.swift (line 501)
Hard-coded Dream Weaver room types: bedroom, living room, bathroom, kitchen, etc.
These are sent to the backend as room_type.
Hard-Coded Defaults Affecting Created Backend Data
21. VelocityAPIClient.swift (line 2579)
Communications task creation defaults priority to "normal".
VelocityAPIClient.swift (line 2969)
CSV import upload defaults source_system to "ipad_csv_upload".
CalendarView.swift (line 1118)
Calendar create adds local metadata: created_from = ipad_calendar, surface = velocity_ipad.
Config And Environment Defaults
24. SessionConfiguration.swift (line 16)
Default endpoint prompt/value: https://velocity.desineuron.in/api.
SessionConfiguration.swift (line 17)
Default Dream Weaver endpoint: https://dreamweaver.desineuron.in.
SessionConfigurationPanel.swift (line 74)
Placeholder operator email: operator@desineuron.in.
Verdict
The highest-priority removals for true production purity are:
Remove Dollhouse procedural fallback or move model selection to backend inventory metadata.
Replace hard-coded CRM vocabularies/stages with /api/crm/vocabularies or similar.
Remove Client 360 minimal fallback so malformed/missing backend data fails visibly.
Stop silently converting failed backend reads into empty arrays.
Keep offline replay only as a pending-write queue, not as “truth” without clear pending-sync labeling.
Replace simulator fake Sunseeker data with unavailable-state only, if simulator mock paths must be totally absent.

View File

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

26
app/dist/index.html vendored
View File

@@ -1,13 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Velocity WebOS</title>
<script type="module" crossorigin src="./assets/index-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-DqpQquIA.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-80rkGqUG.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

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

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ import { Inventory } from '@/components/modules/Inventory';
import { Settings } from '@/components/modules/Settings';
import { Catalyst } from '@/components/modules/Catalyst';
import { CRM } from '@/components/modules/CRM';
import { Comms } from '@/components/modules/Comms';
import { NotificationCenter } from '@/components/layout/NotificationCenter';
import { useCrmBootstrap } from '@/hooks/useCrmBootstrap';
import type { ModuleId } from '@/types';
@@ -53,6 +54,7 @@ export const MODULE_ROUTES: Array<{
{ id: 'inventory', path: '/inventory', title: 'Inventory', component: Inventory },
{ id: 'catalyst', path: '/catalyst', title: 'The Catalyst', component: Catalyst },
{ id: 'crm', path: '/crm', title: 'CRM', component: CRM },
{ id: 'comms', path: '/comms', title: 'Conversations', component: Comms },
{ id: 'settings', path: '/settings', title: 'Settings', component: Settings },
{ id: 'admin', path: '/admin', title: 'Admin', component: AdminPage, adminOnly: true },
];

View File

@@ -22,11 +22,13 @@ import {
X,
MessageSquarePlus,
Sparkles,
Zap,
Brain,
PanelLeft,
type LucideIcon,
} from 'lucide-react';
import { Input } from '@/components/ui/input';
import type { CanvasPage, CanvasPageRevision, MergeRequest, UserProfile } from '@/oracle/types/canvas';
import type { CanvasPage, CanvasPageRevision, MergeRequest, OracleExecutionMode, UserProfile } from '@/oracle/types/canvas';
import type { ComponentRenderContext } from '@/oracle/components/ComponentRegistry';
import { useOraclePage } from '@/oracle/hooks/useOraclePage';
import { useOracleExecution } from '@/oracle/hooks/useOracleExecution';
@@ -60,6 +62,12 @@ const PROMPT_MODES: Array<{ view: string; label: string; samplePrompt: string; i
{ view: 'kpi', label: 'KPI Summary', samplePrompt: 'Give me a KPI summary of total pipeline value today.', icon: BarChart2 },
];
const EXECUTION_MODES: Array<{ value: OracleExecutionMode; label: string; icon: LucideIcon }> = [
{ value: 'auto', label: 'Auto', icon: Sparkles },
{ value: 'fast', label: 'Fast', icon: Zap },
{ value: 'thinking', label: 'Thinking', icon: Brain },
];
const BASE_CTX: ComponentRenderContext = {
tenantId: '',
actorRole: 'sales_director',
@@ -116,6 +124,7 @@ export default function OraclePage() {
const [prompt, setPrompt] = useState('');
const [selectedMode, setSelectedMode] = useState(PROMPT_MODES[0]);
const [executionMode, setExecutionMode] = useState<OracleExecutionMode>('auto');
const [viewDropOpen, setViewDropOpen] = useState(false);
const [listening, setListening] = useState(false);
const [railOpen, setRailOpen] = useState(false);
@@ -208,6 +217,7 @@ export default function OraclePage() {
prompt: clean,
tenantId: me.tenantId,
actorId: me.userId,
executionMode,
placementMode: me.canvasPreferences.defaultPlacementMode,
conversationContext: history.flatMap((entry) => [
{ role: 'user' as const, content: entry.execution.prompt },
@@ -227,7 +237,7 @@ export default function OraclePage() {
}
await Promise.all([refresh(), loadCanvasSessions()]);
}, [prompt, inFlight, page, me, submit, history, applyRevision, refresh, loadCanvasSessions]);
}, [prompt, inFlight, page, me, submit, history, executionMode, applyRevision, refresh, loadCanvasSessions]);
const handleMic = useCallback(() => {
const browserWindow = window as Window & {
@@ -736,6 +746,31 @@ export default function OraclePage() {
{viewDropOpen && <div className="fixed inset-0 z-[998]" onClick={() => setViewDropOpen(false)} />}
</div>
<div
className="flex items-center rounded-full p-0.5"
style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.09)' }}
>
{EXECUTION_MODES.map((mode) => {
const active = executionMode === mode.value;
return (
<button
key={mode.value}
type="button"
onClick={() => setExecutionMode(mode.value)}
className="flex h-7 items-center gap-1.5 rounded-full px-2.5 text-xs transition-all"
style={{
background: active ? 'rgba(59,130,246,0.18)' : 'transparent',
color: active ? '#bfdbfe' : 'rgba(255,255,255,0.48)',
}}
title={mode.value === 'auto' ? 'Let Oracle choose the route' : mode.value === 'fast' ? 'Use Oracle directly' : 'Use Colony orchestration'}
>
<mode.icon className="h-3 w-3" />
<span className={mode.value === 'thinking' ? 'hidden sm:inline' : ''}>{mode.label}</span>
</button>
);
})}
</div>
<button
type="button"
id="oracle-rail-toggle"

View File

@@ -9,6 +9,7 @@ import {
Megaphone,
Shield,
Users,
MessageCircle,
type LucideIcon,
} from 'lucide-react';
import { useStore } from '@/store/useStore';
@@ -21,6 +22,7 @@ const NAV_ICONS: Record<string, LucideIcon> = {
'/sentinel': ScanFace,
'/inventory': Building2,
'/catalyst': Megaphone,
'/comms': MessageCircle,
'/settings': Sliders,
'/admin': Shield,
'/crm': Users,

View File

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

View File

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

View File

@@ -0,0 +1,728 @@
import { useEffect, useRef, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
MessageCircle,
Search,
Send,
Phone,
PhoneCall,
Paperclip,
MoreVertical,
Link as LinkIcon,
User,
Settings,
Bot,
AlertCircle,
CheckCheck,
Clock,
ChevronLeft,
Inbox,
Voicemail,
Plus,
Hash,
} from 'lucide-react';
import { useStore } from '@/store/useStore';
import {
fetchCommsThreads,
fetchCommsMessages,
sendCommsMessage,
linkCommsThreadToPerson,
fetchCommsSettings,
} from '@/lib/commsApi';
import type {
CommsThread,
CommsMessage,
CommsSettings,
SendMessagePayload,
} from '@/types/commsTypes';
/* ── Mock generators for demo / offline mode ─────────────────────────────── */
function generateMockThreads(): CommsThread[] {
const now = new Date().toISOString();
return [
{
threadId: 'mock-1',
phoneE164: '+919876543210',
displayName: 'Rahul Sharma',
channel: 'whatsapp',
status: 'open',
unreadCount: 2,
lastMessageAt: now,
lastMessagePreview: 'Is the 3BHK still available?',
provider: 'mock',
createdAt: now,
updatedAt: now,
crmPerson: {
id: 'p1',
fullName: 'Rahul Sharma',
primaryPhone: '+919876543210',
leadStatus: 'hot',
projectName: 'Atri Aqua',
},
},
{
threadId: 'mock-2',
phoneE164: '+919988776655',
displayName: 'Unknown Number',
channel: 'whatsapp',
status: 'open',
unreadCount: 1,
lastMessageAt: now,
lastMessagePreview: 'Send me the brochure please',
provider: 'mock',
createdAt: now,
updatedAt: now,
},
{
threadId: 'mock-3',
phoneE164: '+911122334455',
displayName: 'Priya Patel',
channel: 'call',
status: 'resolved',
unreadCount: 0,
lastMessageAt: now,
provider: 'mock',
createdAt: now,
updatedAt: now,
crmPerson: {
id: 'p2',
fullName: 'Priya Patel',
primaryPhone: '+911122334455',
leadStatus: 'qualified',
projectName: 'Godrej Elevate',
},
},
];
}
function generateMockMessages(threadId: string): CommsMessage[] {
const now = new Date();
const t1 = new Date(now.getTime() - 1000 * 60 * 60 * 2).toISOString();
const t2 = new Date(now.getTime() - 1000 * 60 * 30).toISOString();
const t3 = new Date(now.getTime() - 1000 * 60 * 5).toISOString();
if (threadId === 'mock-1') {
return [
{
messageId: 'm1',
threadId,
direction: 'inbound',
messageType: 'text',
body: 'Hi, I saw your listing for Atri Aqua. Is the 3BHK still available?',
deliveryStatus: 'read',
createdAt: t1,
provider: 'mock',
senderName: 'Rahul Sharma',
},
{
messageId: 'm2',
threadId,
direction: 'outbound',
messageType: 'text',
body: 'Yes sir, absolutely. We have a premium corner unit on the 12th floor with marina view.',
deliveryStatus: 'read',
createdAt: t2,
provider: 'mock',
},
{
messageId: 'm3',
threadId,
direction: 'inbound',
messageType: 'text',
body: 'What is the final price and can I schedule a visit this weekend?',
deliveryStatus: 'delivered',
createdAt: t3,
provider: 'mock',
senderName: 'Rahul Sharma',
},
];
}
if (threadId === 'mock-2') {
return [
{
messageId: 'm4',
threadId,
direction: 'inbound',
messageType: 'text',
body: 'Send me the brochure please',
deliveryStatus: 'delivered',
createdAt: t3,
provider: 'mock',
senderName: 'Unknown',
},
];
}
return [];
}
/* ── Component ───────────────────────────────────────────────────────────── */
export function Comms() {
useStore();
const [threads, setThreads] = useState<CommsThread[]>([]);
const [activeThreadId, setActiveThreadId] = useState<string | null>(null);
const [messages, setMessages] = useState<CommsMessage[]>([]);
const [settings, setSettings] = useState<CommsSettings | null>(null);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [composerText, setComposerText] = useState('');
const [sending, setSending] = useState(false);
const [mockMode, setMockMode] = useState(false);
const [showCrmRail, setShowCrmRail] = useState(true);
const messagesEndRef = useRef<HTMLDivElement>(null);
const activeThread = threads.find((t) => t.threadId === activeThreadId) || null;
useEffect(() => { loadInitial(); }, []);
useEffect(() => { if (activeThreadId) loadMessages(activeThreadId); }, [activeThreadId]);
useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]);
async function loadInitial() {
try {
setLoading(true);
const [threadsRes, settingsRes] = await Promise.all([
fetchCommsThreads({ limit: 50 }),
fetchCommsSettings().catch(() => null),
]);
setThreads(threadsRes.threads || []);
setSettings(settingsRes);
setMockMode(false);
} catch (e) {
console.warn('Comms backend unavailable, switching to mock mode', e);
setMockMode(true);
setThreads(generateMockThreads());
setSettings({
provider: 'mock',
webhookSecretSet: false,
autoLinkByPhone: false,
createCrmInteractionOnInbound: false,
defaultCountryCode: '91',
});
} finally {
setLoading(false);
}
}
async function loadMessages(threadId: string) {
try {
if (mockMode) {
setMessages(generateMockMessages(threadId));
return;
}
const res = await fetchCommsMessages(threadId, { limit: 100 });
setMessages(res.messages || []);
} catch (e) {
setMessages([]);
}
}
async function handleSend(e?: React.FormEvent) {
e?.preventDefault();
if (!composerText.trim() || !activeThreadId) return;
const payload: SendMessagePayload = {
messageType: 'text',
body: composerText.trim(),
};
const optimistic: CommsMessage = {
messageId: `opt-${Date.now()}`,
threadId: activeThreadId,
direction: 'outbound',
messageType: 'text',
body: payload.body,
deliveryStatus: 'pending',
createdAt: new Date().toISOString(),
provider: activeThread?.provider || 'mock',
};
setMessages((prev) => [...prev, optimistic]);
setComposerText('');
setSending(true);
try {
if (mockMode) {
await new Promise((r) => setTimeout(r, 800));
setMessages((prev) =>
prev.map((m) =>
m.messageId === optimistic.messageId
? { ...m, deliveryStatus: 'sent', messageId: `mock-${Date.now()}` }
: m
)
);
} else {
await sendCommsMessage(activeThreadId, payload);
await loadMessages(activeThreadId);
}
} catch (err) {
setMessages((prev) =>
prev.map((m) =>
m.messageId === optimistic.messageId ? { ...m, deliveryStatus: 'failed' } : m
)
);
} finally {
setSending(false);
}
}
async function handleLinkPerson(personId: string) {
if (!activeThreadId || mockMode) return;
try {
await linkCommsThreadToPerson(activeThreadId, { personId });
await loadInitial();
if (activeThreadId) await loadMessages(activeThreadId);
} catch (e) {
console.error(e);
}
}
const filteredThreads = threads.filter((t) => {
const q = searchQuery.toLowerCase();
return (
(t.displayName || '').toLowerCase().includes(q) ||
t.phoneE164.includes(q) ||
(t.lastMessagePreview || '').toLowerCase().includes(q)
);
});
if (loading) {
return (
<div className="h-full flex items-center justify-center text-zinc-400">
<div className="flex items-center gap-3">
<div className="w-5 h-5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
Loading conversations
</div>
</div>
);
}
/* ── Empty state: provider not configured ──────────────────────────────── */
if (!mockMode && (!settings || settings.provider === 'mock')) {
return (
<div className="h-full flex items-center justify-center">
<div
className="max-w-md w-full p-8 rounded-2xl text-center"
style={{
background: 'hsl(var(--surface))',
border: '1px solid hsl(var(--border-subtle))',
}}
>
<div
className="w-14 h-14 rounded-2xl flex items-center justify-center mx-auto mb-5"
style={{ background: 'hsl(var(--accent) / 0.1)' }}
>
<MessageCircle className="w-7 h-7 text-blue-400" />
</div>
<h2 className="text-lg font-semibold text-white mb-2">Conversations</h2>
<p className="text-sm text-zinc-400 mb-6">
Connect a WhatsApp provider to start receiving and sending messages.
Until then, preview the interface with mock data.
</p>
<div className="flex gap-3 justify-center">
<button
onClick={() => setMockMode(true)}
className="px-4 py-2 rounded-xl text-sm font-medium text-white transition-colors"
style={{ background: 'hsl(var(--surface-2))', border: '1px solid hsl(var(--border-subtle))' }}
>
<Bot className="w-4 h-4 inline mr-2" />
Preview Mock Data
</button>
<button
onClick={() => { /* router push to settings */ }}
className="px-4 py-2 rounded-xl text-sm font-medium text-white transition-colors"
style={{ background: 'hsl(var(--accent))' }}
>
<Settings className="w-4 h-4 inline mr-2" />
Configure Provider
</button>
</div>
</div>
</div>
);
}
return (
<div className="h-full flex gap-4" style={{ minHeight: 0 }}>
{/* ════════════════════════════════════════════════════════════════════
LEFT: Inbox
════════════════════════════════════════════════════════════════════ */}
<aside
className="flex-none flex flex-col rounded-2xl overflow-hidden"
style={{
width: 320,
background: 'hsl(var(--surface))',
border: '1px solid hsl(var(--border-subtle))',
}}
>
<div className="p-4 border-b" style={{ borderColor: 'hsl(var(--border-subtle))' }}>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-white">Inbox</h2>
{mockMode && (
<span className="tag text-amber-400 border-amber-500/20 bg-amber-500/10">
Mock Mode
</span>
)}
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
<input
type="text"
placeholder="Search threads…"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-3 py-2 rounded-xl text-sm text-white placeholder-zinc-500 outline-none"
style={{
background: 'hsl(var(--surface-2))',
border: '1px solid hsl(var(--border-subtle))',
}}
/>
</div>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar">
{filteredThreads.length === 0 ? (
<div className="p-8 text-center text-zinc-500 text-sm">No threads found</div>
) : (
filteredThreads.map((thread) => (
<button
key={thread.threadId}
onClick={() => setActiveThreadId(thread.threadId)}
className="w-full text-left px-4 py-3 transition-colors relative"
style={{
background: activeThreadId === thread.threadId ? 'hsl(var(--accent) / 0.08)' : 'transparent',
borderLeft: activeThreadId === thread.threadId ? '2px solid hsl(var(--accent))' : '2px solid transparent',
}}
>
<div className="flex items-start gap-3">
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold flex-shrink-0"
style={{
background: thread.crmPerson ? 'hsl(var(--accent) / 0.15)' : 'hsl(var(--surface-3))',
color: thread.crmPerson ? 'hsl(var(--accent))' : 'hsl(var(--muted-fg))',
}}
>
{thread.displayName?.[0]?.toUpperCase() || <User className="w-4 h-4" />}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-white truncate">
{thread.displayName || thread.phoneE164}
</span>
{thread.unreadCount > 0 && (
<span className="ml-2 px-1.5 py-0.5 rounded-md text-[10px] font-bold bg-blue-500 text-white">
{thread.unreadCount}
</span>
)}
</div>
<p className="text-xs text-zinc-400 truncate mt-0.5">{thread.lastMessagePreview || 'No messages'}</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-[10px] text-zinc-500">{thread.phoneE164}</span>
{!thread.personId && (
<span className="text-[10px] px-1.5 rounded bg-amber-500/10 text-amber-400 border border-amber-500/20">
Unresolved
</span>
)}
{thread.channel === 'call' && <Phone className="w-3 h-3 text-zinc-500" />}
</div>
</div>
</div>
</button>
))
)}
</div>
</aside>
{/* ════════════════════════════════════════════════════════════════════
CENTER: Conversation Timeline
════════════════════════════════════════════════════════════════════ */}
<section
className="flex-1 flex flex-col rounded-2xl overflow-hidden"
style={{
background: 'hsl(var(--surface))',
border: '1px solid hsl(var(--border-subtle))',
}}
>
{activeThread ? (
<>
{/* ── Client Identity Strip ── */}
<div
className="flex-none px-5 py-3 flex items-center justify-between border-b"
style={{ borderColor: 'hsl(var(--border-subtle))' }}
>
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold"
style={{
background: activeThread.crmPerson ? 'hsl(var(--accent) / 0.15)' : 'hsl(var(--surface-3))',
color: activeThread.crmPerson ? 'hsl(var(--accent))' : 'hsl(var(--muted-fg))',
}}
>
{activeThread.displayName?.[0]?.toUpperCase() || <User className="w-4 h-4" />}
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-white">
{activeThread.displayName || activeThread.phoneE164}
</h3>
{activeThread.channel === 'whatsapp' && (
<span className="tag text-emerald-400 border-emerald-500/20 bg-emerald-500/10 text-[10px]">
WhatsApp
</span>
)}
{activeThread.channel === 'call' && (
<span className="tag text-blue-300 border-blue-400/20 bg-blue-400/10 text-[10px]">
Call
</span>
)}
</div>
<p className="text-xs text-zinc-400">{activeThread.phoneE164}</p>
</div>
</div>
<div className="flex items-center gap-2">
<button className="p-2 rounded-lg hover:bg-white/5 text-zinc-400 hover:text-white transition-colors">
<PhoneCall className="w-4 h-4" />
</button>
<button className="p-2 rounded-lg hover:bg-white/5 text-zinc-400 hover:text-white transition-colors">
<MoreVertical className="w-4 h-4" />
</button>
<button
onClick={() => setShowCrmRail((v) => !v)}
className="p-2 rounded-lg hover:bg-white/5 text-zinc-400 hover:text-white transition-colors"
title="Toggle CRM rail"
>
<ChevronLeft className={`w-4 h-4 transition-transform ${showCrmRail ? '' : 'rotate-180'}`} />
</button>
</div>
</div>
{/* ── Messages ── */}
<div className="flex-1 overflow-y-auto custom-scrollbar px-5 py-4 space-y-4">
<AnimatePresence initial={false}>
{messages.map((msg) => (
<motion.div
key={msg.messageId}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className={`flex ${msg.direction === 'outbound' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[70%] px-4 py-2.5 rounded-2xl text-sm ${
msg.direction === 'outbound' ? 'rounded-br-md' : 'rounded-bl-md'
}`}
style={{
background:
msg.direction === 'outbound'
? 'hsl(var(--accent) / 0.9)'
: 'hsl(var(--surface-3))',
color: msg.direction === 'outbound' ? '#fff' : 'hsl(var(--foreground))',
}}
>
{msg.messageType === 'text' && <p className="leading-relaxed">{msg.body}</p>}
{msg.mediaUrl && (
<div className="mt-2 rounded-lg overflow-hidden">
<img src={msg.mediaUrl} alt="media" className="max-w-full max-h-48 object-cover" />
</div>
)}
<div className="flex items-center justify-end gap-1.5 mt-1.5">
<span className="text-[10px] opacity-60">
{new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
{msg.direction === 'outbound' && (
<>
{msg.deliveryStatus === 'pending' && <Clock className="w-3 h-3 opacity-60" />}
{msg.deliveryStatus === 'sent' && <CheckCheck className="w-3 h-3 opacity-60" />}
{msg.deliveryStatus === 'delivered' && <CheckCheck className="w-3 h-3 text-blue-200" />}
{msg.deliveryStatus === 'read' && <CheckCheck className="w-3 h-3 text-emerald-300" />}
{msg.deliveryStatus === 'failed' && <AlertCircle className="w-3 h-3 text-red-300" />}
</>
)}
</div>
</div>
</motion.div>
))}
</AnimatePresence>
<div ref={messagesEndRef} />
</div>
{/* ── Composer ── */}
<div
className="flex-none px-4 py-3 border-t"
style={{ borderColor: 'hsl(var(--border-subtle))' }}
>
{!activeThread.personId && (
<div
className="mb-3 px-3 py-2 rounded-xl flex items-center gap-2 text-xs"
style={{
background: 'hsl(38 92% 50% / 0.08)',
border: '1px solid hsl(38 92% 50% / 0.2)',
color: 'hsl(38 92% 65%)',
}}
>
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span className="flex-1">This number is not linked to a CRM contact.</span>
<button
onClick={() => handleLinkPerson('demo-person-id')}
className="px-2 py-1 rounded-md bg-amber-500/10 hover:bg-amber-500/20 transition-colors font-medium"
>
Link
</button>
</div>
)}
<form onSubmit={handleSend} className="flex items-end gap-2">
<button
type="button"
className="p-2.5 rounded-xl text-zinc-400 hover:text-white hover:bg-white/5 transition-colors"
>
<Paperclip className="w-5 h-5" />
</button>
<div className="flex-1 relative">
<textarea
rows={1}
value={composerText}
onChange={(e) => setComposerText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
placeholder="Type a message…"
className="w-full px-4 py-2.5 rounded-xl text-sm text-white placeholder-zinc-500 outline-none resize-none max-h-32"
style={{
background: 'hsl(var(--surface-2))',
border: '1px solid hsl(var(--border-subtle))',
}}
/>
</div>
<button
type="submit"
disabled={!composerText.trim() || sending}
className="p-2.5 rounded-xl text-white transition-all disabled:opacity-40 disabled:cursor-not-allowed"
style={{ background: 'hsl(var(--accent))' }}
>
<Send className="w-5 h-5" />
</button>
</form>
</div>
</>
) : (
<div className="flex-1 flex flex-col items-center justify-center text-zinc-500">
<Inbox className="w-12 h-12 mb-4 opacity-20" />
<p className="text-sm">Select a conversation to start</p>
</div>
)}
</section>
{/* ════════════════════════════════════════════════════════════════════
RIGHT: CRM Intelligence Rail
════════════════════════════════════════════════════════════════════ */}
<AnimatePresence>
{showCrmRail && activeThread && (
<motion.aside
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
className="flex-none flex flex-col rounded-2xl overflow-hidden"
style={{
width: 300,
background: 'hsl(var(--surface))',
border: '1px solid hsl(var(--border-subtle))',
}}
>
<div className="p-4 border-b" style={{ borderColor: 'hsl(var(--border-subtle))' }}>
<h3 className="text-sm font-semibold text-white">CRM Intelligence</h3>
<p className="text-xs text-zinc-500 mt-0.5">Velocity Lens · {activeThread.phoneE164}</p>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar p-4 space-y-4">
{activeThread.crmPerson ? (
<>
<div
className="p-4 rounded-xl"
style={{ background: 'hsl(var(--surface-2))', border: '1px solid hsl(var(--border-subtle))' }}
>
<div className="flex items-center gap-3 mb-3">
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold"
style={{ background: 'hsl(var(--accent) / 0.15)', color: 'hsl(var(--accent))' }}
>
{activeThread.crmPerson.fullName?.[0] || 'C'}
</div>
<div>
<p className="text-sm font-medium text-white">{activeThread.crmPerson.fullName || 'Unknown'}</p>
<p className="text-xs text-zinc-400">{activeThread.crmPerson.primaryEmail || 'No email'}</p>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between text-xs">
<span className="text-zinc-500">Status</span>
<span className="text-white capitalize">{activeThread.crmPerson.leadStatus || 'New'}</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-zinc-500">Project</span>
<span className="text-white">{activeThread.crmPerson.projectName || '-'}</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-zinc-500">Buyer Type</span>
<span className="text-white">{activeThread.crmPerson.buyerType || '-'}</span>
</div>
</div>
</div>
<div
className="p-4 rounded-xl"
style={{ background: 'hsl(var(--surface-2))', border: '1px solid hsl(var(--border-subtle))' }}
>
<h4 className="text-xs font-semibold text-zinc-300 uppercase tracking-wider mb-3">Next Best Action</h4>
<p className="text-sm text-white font-medium">Schedule site visit</p>
<p className="text-xs text-zinc-400 mt-1">High intent signal detected</p>
</div>
<div
className="p-4 rounded-xl"
style={{ background: 'hsl(var(--surface-2))', border: '1px solid hsl(var(--border-subtle))' }}
>
<h4 className="text-xs font-semibold text-zinc-300 uppercase tracking-wider mb-3">Quick Actions</h4>
<div className="space-y-2">
<button className="w-full text-left px-3 py-2 rounded-lg text-xs text-white hover:bg-white/5 transition-colors flex items-center gap-2">
<Phone className="w-3.5 h-3.5 text-zinc-400" />
Log Call
</button>
<button className="w-full text-left px-3 py-2 rounded-lg text-xs text-white hover:bg-white/5 transition-colors flex items-center gap-2">
<Voicemail className="w-3.5 h-3.5 text-zinc-400" />
Add Recording
</button>
<button className="w-full text-left px-3 py-2 rounded-lg text-xs text-white hover:bg-white/5 transition-colors flex items-center gap-2">
<Plus className="w-3.5 h-3.5 text-zinc-400" />
Create Task
</button>
</div>
</div>
</>
) : (
<div
className="p-4 rounded-xl text-center"
style={{ background: 'hsl(var(--surface-2))', border: '1px solid hsl(var(--border-subtle))' }}
>
<Hash className="w-8 h-8 text-amber-500/60 mx-auto mb-3" />
<p className="text-sm text-white font-medium mb-1">Unresolved Number</p>
<p className="text-xs text-zinc-400 mb-4">
No CRM match found for {activeThread.phoneE164}
</p>
<button
onClick={() => handleLinkPerson('demo-person-id')}
className="w-full px-3 py-2 rounded-lg text-xs font-medium text-white transition-colors"
style={{ background: 'hsl(var(--accent))' }}
>
<LinkIcon className="w-3.5 h-3.5 inline mr-1.5" />
Link to Contact
</button>
</div>
)}
</div>
</motion.aside>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useRef, useState, type ChangeEvent } from 'react';
import { useEffect, useRef, useState, type ChangeEvent } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
User,
@@ -16,12 +16,15 @@ import {
ChevronDown,
LogOut,
Pencil,
MessageCircle,
type LucideIcon,
} from 'lucide-react';
import { useStore } from '@/store/useStore';
import { useCurrency, CURRENCY_OPTIONS } from '@/store/useCurrencyStore';
import type { CurrencyCode } from '@/store/useCurrencyStore';
import { API_URL } from '@/lib/api';
import { fetchCommsSettings, testCommsProviderConnection, updateCommsSettings } from '@/lib/commsApi';
import type { CommsProvider, CommsSettings } from '@/types/commsTypes';
import {
clearVelocityToken,
getVelocityToken,
@@ -613,6 +616,160 @@ function DisplaySettings() {
}
// ── Data & Privacy ───────────────────────────────────────────────────────────
function CommunicationsSettings() {
const [settings, setSettings] = useState<CommsSettings | null>(null);
const [draft, setDraft] = useState<Partial<CommsSettings>>({});
const [statusText, setStatusText] = useState('Loading provider settings...');
const [saving, setSaving] = useState(false);
const current: CommsSettings = {
provider: 'mock',
providerBaseUrl: '',
providerApiKey: '',
instanceId: '',
phoneNumberId: '',
webhookCallbackUrl: '/api/comms/webhooks/{provider}',
webhookSecretSet: false,
autoLinkByPhone: true,
createCrmInteractionOnInbound: true,
defaultCountryCode: '91',
transcriptionProvider: 'none',
...(settings ?? {}),
...draft,
};
useEffect(() => {
let cancelled = false;
void fetchCommsSettings()
.then((value) => {
if (cancelled) return;
setSettings(value);
setStatusText('Settings loaded from backend.');
})
.catch((error) => {
if (cancelled) return;
setStatusText(error instanceof Error ? error.message : 'Unable to load comms settings.');
});
return () => {
cancelled = true;
};
}, []);
const update = <K extends keyof CommsSettings>(key: K, value: CommsSettings[K]) => {
setDraft((prev) => ({ ...prev, [key]: value }));
};
const save = async () => {
setSaving(true);
try {
await updateCommsSettings(draft);
const latest = await fetchCommsSettings();
setSettings(latest);
setDraft({});
setStatusText('Communications settings saved.');
} catch (error) {
setStatusText(error instanceof Error ? error.message : 'Failed to save communications settings.');
} finally {
setSaving(false);
}
};
const test = async () => {
try {
const result = await testCommsProviderConnection();
setStatusText(result.message || (result.success ? 'Provider connection succeeded.' : 'Provider connection failed.'));
} catch (error) {
setStatusText(error instanceof Error ? error.message : 'Provider test failed.');
}
};
const fieldClass = "w-64 rounded-xl px-3 py-2 text-sm text-white placeholder-zinc-500 outline-none";
return (
<GlassCard delay={0.3}>
<SectionHeader icon={MessageCircle} title="Communications" accent="#22d3ee" />
<div>
<SettingsRow label="Provider" description="Mock is local preview. WAHA and Evolution require a running provider service.">
<DarkSelect
value={current.provider}
onChange={(v) => update('provider', v as CommsProvider)}
options={[
{ value: 'mock', label: 'Mock' },
{ value: 'waha', label: 'WAHA' },
{ value: 'evolution', label: 'Evolution API' },
{ value: 'meta_cloud', label: 'Meta Cloud API' },
]}
/>
</SettingsRow>
<SettingsRow label="Provider Base URL" description="Internal or public base URL for WAHA/Evolution.">
<input
className={fieldClass}
style={INNER_SURFACE}
value={current.providerBaseUrl ?? ''}
onChange={(event) => update('providerBaseUrl', event.target.value)}
placeholder="http://127.0.0.1:3000"
/>
</SettingsRow>
<SettingsRow label="API Key" description="Stored in backend comms settings. Masked when read back.">
<input
className={fieldClass}
style={INNER_SURFACE}
type="password"
value={current.providerApiKey ?? ''}
onChange={(event) => update('providerApiKey', event.target.value)}
placeholder="Provider API key"
/>
</SettingsRow>
<SettingsRow label="Instance / Session" description="WAHA session name or Evolution instance name.">
<input
className={fieldClass}
style={INNER_SURFACE}
value={current.instanceId ?? ''}
onChange={(event) => update('instanceId', event.target.value)}
placeholder="default"
/>
</SettingsRow>
<SettingsRow label="Webhook URL" description="Point provider inbound webhooks here.">
<span className="text-xs font-mono text-zinc-300">{current.webhookCallbackUrl || '/api/comms/webhooks/{provider}'}</span>
</SettingsRow>
<SettingsRow label="Auto-link by Phone" description="Match inbound numbers to crm_people.primary_phone.">
<Toggle enabled={Boolean(current.autoLinkByPhone)} onChange={(v) => update('autoLinkByPhone', v)} />
</SettingsRow>
<SettingsRow label="Create CRM Interaction" description="Mirror inbound messages into canonical intelligence tables.">
<Toggle enabled={Boolean(current.createCrmInteractionOnInbound)} onChange={(v) => update('createCrmInteractionOnInbound', v)} />
</SettingsRow>
<SettingsRow label="Transcription Provider" description="Recording intake is stored now; transcription worker can be added later.">
<DarkSelect
value={current.transcriptionProvider ?? 'none'}
onChange={(v) => update('transcriptionProvider', v as CommsSettings['transcriptionProvider'])}
options={[
{ value: 'none', label: 'None' },
{ value: 'local', label: 'Local Whisper' },
{ value: 'openai', label: 'OpenAI' },
]}
/>
</SettingsRow>
<div className="px-6 py-4 flex items-center justify-between gap-3">
<p className="text-xs text-zinc-400">{statusText}</p>
<div className="flex items-center gap-2">
<GhostButton onClick={test}>Test</GhostButton>
<motion.button
type="button"
onClick={save}
disabled={saving || Object.keys(draft).length === 0}
className="px-4 py-2 rounded-xl text-sm font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
style={{ background: 'hsl(var(--accent))', color: 'hsl(var(--accent-fg))' }}
whileHover={{ scale: saving ? 1 : 1.02 }}
whileTap={{ scale: saving ? 1 : 0.97 }}
>
{saving ? 'Saving...' : 'Save'}
</motion.button>
</div>
</div>
</div>
</GlassCard>
);
}
function DataSettings() {
const [retention, setRetention] = useState('90');
const { leads, messages, units, status } = useStore();
@@ -728,9 +885,14 @@ export function Settings() {
<DisplaySettings />
</div>
{/* Row 4: Data + About */}
{/* Row 4: Communications + Data */}
<div className="grid grid-cols-2 gap-4 relative z-10">
<CommunicationsSettings />
<DataSettings />
</div>
{/* Row 5: About */}
<div className="grid grid-cols-1 gap-4 relative z-0">
<AboutSection />
</div>
</div>

View File

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

89
app/src/lib/commsApi.ts Normal file
View File

@@ -0,0 +1,89 @@
import { getVelocityToken } from './velocityPlatformClient';
import type {
CommsThread,
CommsSettings,
CommsProviderTestResult,
SendMessagePayload,
CommsThreadListResponse,
CommsMessageListResponse,
ThreadLinkPayload,
} from '@/types/commsTypes';
const API_BASE = import.meta.env.VITE_API_BASE_URL || '';
async function commsFetch(path: string, options?: RequestInit) {
const token = getVelocityToken();
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options?.headers,
},
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${res.status}`);
}
return res.json();
}
function toQuery(params?: Record<string, string | number | undefined>) {
const query = new URLSearchParams();
Object.entries(params ?? {}).forEach(([key, value]) => {
if (value !== undefined && value !== '') query.set(key, String(value));
});
const serialized = query.toString();
return serialized ? `?${serialized}` : '';
}
export const fetchCommsThreads = (params?: { status?: string; search?: string; limit?: number; offset?: number }) =>
commsFetch(`/api/comms/threads${toQuery(params)}`) as Promise<CommsThreadListResponse>;
export const fetchCommsThread = (threadId: string) =>
commsFetch(`/api/comms/threads/${threadId}`) as Promise<CommsThread>;
export const fetchCommsMessages = (threadId: string, params?: { limit?: number; offset?: number }) =>
commsFetch(`/api/comms/threads/${threadId}/messages${toQuery(params)}`) as Promise<CommsMessageListResponse>;
export const sendCommsMessage = (threadId: string, payload: SendMessagePayload) =>
commsFetch(`/api/comms/threads/${threadId}/messages`, {
method: 'POST',
body: JSON.stringify(payload),
});
export const linkCommsThreadToPerson = (threadId: string, payload: ThreadLinkPayload) =>
commsFetch(`/api/comms/threads/${threadId}/link-person`, {
method: 'POST',
body: JSON.stringify(payload),
});
export const addCommsThreadNote = (threadId: string, body: { content: string }) =>
commsFetch(`/api/comms/threads/${threadId}/notes`, {
method: 'POST',
body: JSON.stringify(body),
});
export const addCommsThreadTask = (threadId: string, body: { title: string; dueAt?: string }) =>
commsFetch(`/api/comms/threads/${threadId}/tasks`, {
method: 'POST',
body: JSON.stringify(body),
});
export const fetchCommsSettings = () =>
commsFetch(`/api/comms/settings`) as Promise<CommsSettings>;
export const updateCommsSettings = (payload: Partial<CommsSettings>) =>
commsFetch(`/api/comms/settings`, {
method: 'PATCH',
body: JSON.stringify(payload),
});
export const testCommsProviderConnection = () =>
commsFetch(`/api/comms/provider/test`, { method: 'POST' }) as Promise<CommsProviderTestResult>;
export const transcribeCommsRecording = (callId: string) =>
commsFetch(`/api/comms/recordings/transcribe`, {
method: 'POST',
body: JSON.stringify({ callId }),
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
* useOracleExecution — manages prompt submission and durable execution history.
*/
import { useState, useCallback, useRef } from 'react';
import type { PromptExecution, CanvasComponent, PlacementMode } from '../types/canvas';
import type { PromptExecution, CanvasComponent, PlacementMode, OracleExecutionMode } from '../types/canvas';
import { submitPrompt } from '../lib/oracleApiClient';
export interface ExecutionEntry {
@@ -20,6 +20,7 @@ export interface OracleExecutionState {
prompt: string;
tenantId: string;
actorId: string;
executionMode?: OracleExecutionMode;
placementMode?: PlacementMode;
conversationContext?: Array<{ role: 'user' | 'assistant'; content: string }>;
onExecutionCommitted?: (commit: {
@@ -45,6 +46,7 @@ export function useOracleExecution(): OracleExecutionState {
prompt,
tenantId,
actorId,
executionMode = 'auto',
placementMode = 'append_after_last_visible_component',
conversationContext = [],
onExecutionCommitted,
@@ -54,6 +56,7 @@ export function useOracleExecution(): OracleExecutionState {
prompt: string;
tenantId: string;
actorId: string;
executionMode?: OracleExecutionMode;
placementMode?: PlacementMode;
conversationContext?: Array<{ role: 'user' | 'assistant'; content: string }>;
onExecutionCommitted?: (commit: {
@@ -73,10 +76,11 @@ export function useOracleExecution(): OracleExecutionState {
prompt,
intentClass: 'analytical',
status: 'planning',
modelRuntime: 'oracle_runtime',
modelRuntime: executionMode === 'thinking' ? 'colony_orchestrator' : 'oracle_runtime',
semanticModelVersion: 'oracle_semantic_v2026_04_08_01',
warnings: [],
createdAt: now,
executionMode,
};
setInFlight(optimistic);
@@ -91,6 +95,7 @@ export function useOracleExecution(): OracleExecutionState {
prompt,
conversationContext,
placementMode,
executionMode,
});
const completed: PromptExecution = {
@@ -100,6 +105,9 @@ export function useOracleExecution(): OracleExecutionState {
summary: response.summary,
warnings: response.warnings,
componentsCreated: response.componentsCreated,
executionMode: response.executionMode ?? executionMode,
resolvedMode: response.resolvedMode,
colonyMissionId: response.colonyMissionId,
completedAt: new Date().toISOString(),
};

View File

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

View File

@@ -50,6 +50,8 @@ export type ExecutionStatus =
| 'failed'
| 'clarification_required';
export type OracleExecutionMode = 'auto' | 'fast' | 'thinking';
export type PageType = 'main' | 'fork';
export type ForkStatus = 'active' | 'merged' | 'closed';
@@ -280,6 +282,9 @@ export interface PromptExecution {
componentsCreated?: string[];
createdAt: string;
completedAt?: string;
executionMode?: OracleExecutionMode;
resolvedMode?: 'fast' | 'thinking';
colonyMissionId?: string;
}
export interface ComponentTemplate {
@@ -383,6 +388,7 @@ export interface PromptSubmitRequest {
prompt: string;
conversationContext?: Array<{ role: 'user' | 'assistant'; content: string }>;
placementMode?: PlacementMode;
executionMode?: OracleExecutionMode;
}
export interface PromptSubmitResponse {
@@ -395,6 +401,9 @@ export interface PromptSubmitResponse {
components: CanvasComponent[];
summary: string;
warnings: string[];
executionMode?: OracleExecutionMode;
resolvedMode?: 'fast' | 'thinking';
colonyMissionId?: string;
}
export interface CanvasPageRevision {

View File

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

121
app/src/types/commsTypes.ts Normal file
View File

@@ -0,0 +1,121 @@
// Velocity Comms Types
// Native types for the Conversations module
export type CommsChannel = 'whatsapp' | 'sms' | 'call';
export type CommsThreadStatus = 'open' | 'resolved' | 'spam' | 'archived';
export type CommsMessageDirection = 'inbound' | 'outbound' | 'system';
export type CommsMessageType = 'text' | 'image' | 'video' | 'audio' | 'document' | 'location' | 'template';
export type CommsProvider = 'mock' | 'waha' | 'evolution' | 'meta_cloud';
export type CommsDeliveryStatus = 'pending' | 'sent' | 'delivered' | 'read' | 'failed';
export interface CommsThread {
threadId: string;
provider: CommsProvider;
externalThreadId?: string;
personId?: string;
phoneE164: string;
displayName?: string;
channel: CommsChannel;
status: CommsThreadStatus;
assignedUserId?: string;
lastMessageAt?: string;
unreadCount: number;
metadataJson?: Record<string, unknown>;
createdAt: string;
updatedAt: string;
crmPerson?: {
id: string;
fullName?: string;
primaryPhone?: string;
primaryEmail?: string;
buyerType?: string;
leadStatus?: string;
projectName?: string;
};
lastMessagePreview?: string;
}
export interface CommsMessage {
messageId: string;
threadId: string;
provider: CommsProvider;
externalMessageId?: string;
direction: CommsMessageDirection;
messageType: CommsMessageType;
body: string;
mediaUrl?: string;
mediaMimeType?: string;
deliveryStatus: CommsDeliveryStatus;
sentAt?: string;
deliveredAt?: string;
readAt?: string;
rawPayload?: Record<string, unknown>;
createdAt: string;
senderName?: string;
senderAvatar?: string;
}
export interface CommsCallLog {
callId: string;
threadId?: string;
personId?: string;
provider: CommsProvider;
externalCallId?: string;
phoneE164: string;
direction: 'inbound' | 'outbound';
status: 'ringing' | 'answered' | 'missed' | 'voicemail' | 'completed';
startedAt: string;
endedAt?: string;
durationSeconds?: number;
recordingUrl?: string;
transcriptId?: string;
transcriptText?: string;
rawPayload?: Record<string, unknown>;
createdAt: string;
}
export interface CommsSettings {
provider: CommsProvider;
providerBaseUrl?: string;
providerApiKey?: string;
instanceId?: string;
phoneNumberId?: string;
webhookCallbackUrl?: string;
webhookSecretSet: boolean;
defaultAssignmentUserId?: string;
autoLinkByPhone: boolean;
createCrmInteractionOnInbound: boolean;
defaultCountryCode: string;
mediaStorageDir?: string;
transcriptionProvider?: 'none' | 'openai' | 'local';
updatedAt?: string;
}
export interface ThreadLinkPayload {
personId: string;
}
export interface SendMessagePayload {
messageType: CommsMessageType;
body: string;
mediaUrl?: string;
templateName?: string;
templateLanguage?: string;
}
export interface CommsProviderTestResult {
success: boolean;
message: string;
accountInfo?: Record<string, unknown>;
}
export interface CommsThreadListResponse {
threads: CommsThread[];
total: number;
unreadTotal: number;
}
export interface CommsMessageListResponse {
messages: CommsMessage[];
thread: CommsThread;
}

View File

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

View File

@@ -1,5 +1,5 @@
// Navigation Module Types
export type ModuleId = 'dashboard' | 'oracle' | 'sentinel' | 'inventory' | 'settings' | 'catalyst' | 'admin' | 'crm';
export type ModuleId = 'dashboard' | 'oracle' | 'sentinel' | 'inventory' | 'settings' | 'catalyst' | 'admin' | 'crm' | 'comms';
export type SentinelSubTab = 'overview' | 'live-session';

View File

@@ -7,6 +7,11 @@ META_ACCESS_TOKEN=PLACEHOLDER_your_meta_system_user_token
# Meta Business Manager → Ad Accounts
META_AD_ACCOUNT_ID=PLACEHOLDER_act_1234567890
# Page publishing values for Catalyst social posting
META_PAGE_ACCESS_TOKEN=PLACEHOLDER_your_meta_page_access_token
META_PAGE_ID=PLACEHOLDER_1234567890
META_INSTAGRAM_BUSINESS_ID=PLACEHOLDER_17841400000000000
# Business Portfolio ID
# Meta Business Settings → Business Info → Business ID
META_BUSINESS_ID=PLACEHOLDER_1234567890
@@ -32,6 +37,16 @@ SUPABASE_SERVICE_ROLE_KEY=PLACEHOLDER_your_supabase_service_role_key
# Base URL of ComfyUI server running locally or on GPU node
COMFY_BASE_URL=http://localhost:8188
# ── Colony Orchestration ─────────────────────────────────────────────────────
# Real colony orchestrator service URL. Required; no local mock fallback is used.
COLONY_SERVICE_URL=PLACEHOLDER_http://localhost:8090
COLONY_TIMEOUT_SECONDS=30
# ── Social Posting ───────────────────────────────────────────────────────────
LINKEDIN_ACCESS_TOKEN=PLACEHOLDER_your_linkedin_access_token
LINKEDIN_ORG_ID=PLACEHOLDER_123456
TWITTER_BEARER_TOKEN=PLACEHOLDER_your_twitter_bearer_token
# —— Shared Desineuron coding / Oracle / NemoClaw runtime —————————————————————
# Stable OpenAI-compatible SGLang route rendered through ingress.
LLM_BASE_URL=https://llm.desineuron.in

View File

@@ -0,0 +1,249 @@
# Project Velocity production environment template.
# Copy to backend/.env.production on the deployment host, or map these names into
# your secrets manager / systemd EnvironmentFile. Keep real values out of git.
# -----------------------------------------------------------------------------
# Runtime / Deployment
# -----------------------------------------------------------------------------
ENVIRONMENT=production
VELOCITY_ENV_FILE=/opt/velocity/backend/.env.production
VELOCITY_PUBLIC_BACKEND_URL=https://api.desineuron.in
VELOCITY_API_BASE_URL=https://api.desineuron.in
VELOCITY_DREAM_WEAVER_URL=https://dreamweaver.desineuron.in
VELOCITY_DEFAULT_TENANT_ID=tenant_velocity
VELOCITY_DEMO_TENANT_ID=tenant_velocity
VELOCITY_DEMO_OPERATOR_EMAIL=
CORS_ORIGINS=https://velocity.desineuron.in,https://api.desineuron.in
TRUSTED_HOSTS=api.desineuron.in,dreamweaver.desineuron.in,velocity.desineuron.in
LOG_LEVEL=INFO
# -----------------------------------------------------------------------------
# PostgreSQL
# -----------------------------------------------------------------------------
# Prefer DATABASE_URL in production. VELOCITY_DB_* is retained for services and
# seed scripts that construct asyncpg pools from discrete credentials.
DATABASE_URL=
VELOCITY_DB_HOST=
VELOCITY_DB_PORT=5432
VELOCITY_DB_NAME=
VELOCITY_DB_USER=
VELOCITY_DB_PASSWORD=
VELOCITY_DB_SSLMODE=require
# Optional read-only Oracle database credentials for natural-language DB agent.
ORACLE_READ_DATABASE_URL=
VELOCITY_DB_READ_HOST=
VELOCITY_DB_READ_PORT=5432
VELOCITY_DB_READ_NAME=
VELOCITY_DB_READ_USER=
VELOCITY_DB_READ_PASSWORD=
# -----------------------------------------------------------------------------
# Auth / JWT / Sessions
# -----------------------------------------------------------------------------
VELOCITY_JWT_SECRET=
SECRET_KEY=
VELOCITY_PASSWORD_RECOVERY_MINUTES=30
# Set to true only in a sealed internal test environment; never on public prod.
VELOCITY_AUTH_RETURN_RECOVERY_TOKEN=false
# -----------------------------------------------------------------------------
# Enterprise SSO: OAuth / OIDC / SAML
# -----------------------------------------------------------------------------
# Comma-separated provider IDs exposed to the iPad Settings screen.
# Example: VELOCITY_SSO_PROVIDERS=azure_ad,okta
VELOCITY_SSO_PROVIDERS=
VELOCITY_DEFAULT_SSO_PROVIDER=
# OAuth/OIDC provider: Azure AD.
VELOCITY_SSO_AZURE_AD_TYPE=oauth
VELOCITY_SSO_AZURE_AD_NAME=Azure AD
VELOCITY_SSO_AZURE_AD_ISSUER=
VELOCITY_SSO_AZURE_AD_METADATA_URL=
VELOCITY_SSO_AZURE_AD_AUTH_URL=
VELOCITY_SSO_AZURE_AD_TOKEN_URL=
VELOCITY_SSO_AZURE_AD_CLIENT_ID=
VELOCITY_SSO_AZURE_AD_CLIENT_SECRET=
VELOCITY_SSO_AZURE_AD_REDIRECT_URI=https://api.desineuron.in/api/auth/sso/azure_ad/callback
# OAuth/OIDC provider: Okta.
VELOCITY_SSO_OKTA_TYPE=oauth
VELOCITY_SSO_OKTA_NAME=Okta
VELOCITY_SSO_OKTA_ISSUER=
VELOCITY_SSO_OKTA_METADATA_URL=
VELOCITY_SSO_OKTA_AUTH_URL=
VELOCITY_SSO_OKTA_TOKEN_URL=
VELOCITY_SSO_OKTA_CLIENT_ID=
VELOCITY_SSO_OKTA_CLIENT_SECRET=
VELOCITY_SSO_OKTA_REDIRECT_URI=https://api.desineuron.in/api/auth/sso/okta/callback
# SAML provider values for enterprise tenants that require SAML.
VELOCITY_SAML_ENTITY_ID=
VELOCITY_SAML_SSO_URL=
VELOCITY_SAML_CERTIFICATE_PEM=
VELOCITY_SAML_PRIVATE_KEY_PEM=
VELOCITY_SAML_ASSERTION_CONSUMER_SERVICE_URL=https://api.desineuron.in/api/auth/saml/acs
# -----------------------------------------------------------------------------
# MDM / Managed App Configuration
# -----------------------------------------------------------------------------
VELOCITY_MDM_REQUIRED=true
VELOCITY_MDM_ORG_NAME=
VELOCITY_MDM_SUPPORT_EMAIL=
# -----------------------------------------------------------------------------
# Communications: WAHA / Evolution / Meta WhatsApp
# -----------------------------------------------------------------------------
# COMMS_PROVIDER valid values: waha, evolution, mock.
COMMS_PROVIDER=waha
COMMS_PROVIDER_BASE_URL=
COMMS_PROVIDER_API_KEY=
COMMS_INSTANCE_ID=
COMMS_DEFAULT_COUNTRY_CODE=91
COMMS_WEBHOOK_SECRET=
COMMS_MEDIA_STORAGE_DIR=/opt/dlami/nvme/assets/comms
# WAHA-specific values, if production uses WAHA directly.
WAHA_BASE_URL=
WAHA_API_KEY=
WAHA_SESSION=velocity-production
WAHA_WEBHOOK_SECRET=
WAHA_WEBHOOK_CALLBACK_URL=https://api.desineuron.in/api/comms/webhooks/waha
# Evolution API-specific values, if production uses Evolution.
EVOLUTION_BASE_URL=
EVOLUTION_API_KEY=
EVOLUTION_INSTANCE_ID=
EVOLUTION_WEBHOOK_SECRET=
EVOLUTION_WEBHOOK_CALLBACK_URL=https://api.desineuron.in/api/comms/webhooks/evolution
# Meta Graph / WhatsApp Cloud API values.
META_ACCESS_TOKEN=
META_APP_ID=
META_APP_SECRET=
META_BUSINESS_ID=
META_AD_ACCOUNT_ID=
META_PAGE_ACCESS_TOKEN=
META_PAGE_ID=
META_INSTAGRAM_BUSINESS_ID=
META_PHONE_NUMBER_ID=
META_WHATSAPP_BUSINESS_ACCOUNT_ID=
META_WEBHOOK_VERIFY_TOKEN=
META_API_VERSION=v21.0
# -----------------------------------------------------------------------------
# Communications Transcription Providers
# -----------------------------------------------------------------------------
# COMMS_TRANSCRIPTION_PROVIDER valid values: openai, deepgram, http, none.
COMMS_TRANSCRIPTION_PROVIDER=openai
COMMS_TRANSCRIPTION_LANGUAGE=en
OPENAI_API_KEY=
COMMS_OPENAI_TRANSCRIPTION_MODEL=whisper-1
DEEPGRAM_API_KEY=
COMMS_DEEPGRAM_MODEL=nova-2
COMMS_TRANSCRIPTION_ENDPOINT=
COMMS_TRANSCRIPTION_ENDPOINT_TOKEN=
# -----------------------------------------------------------------------------
# Media Storage / AWS S3
# -----------------------------------------------------------------------------
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_SESSION_TOKEN=
AWS_REGION=ap-south-1
AWS_S3_BUCKET=
AWS_S3_PUBLIC_BASE_URL=
AWS_S3_MEDIA_PREFIX=velocity-production
VELOCITY_ASSET_DIR=/opt/dlami/nvme/assets
VELOCITY_VIDEO_DIR=/opt/dlami/nvme/assets/videos
# -----------------------------------------------------------------------------
# Dream Weaver / ComfyUI / GPU Gateway
# -----------------------------------------------------------------------------
COMFY_BASE_URL=http://127.0.0.1:8188
DREAM_WEAVER_GATEWAY_URL=https://dreamweaver.desineuron.in
DREAM_WEAVER_API_KEY=
COMFY_CHECKPOINT_NAME=
COMFY_WORKFLOW_DIR=/opt/dlami/nvme/velocity/comfy_workflows
# -----------------------------------------------------------------------------
# LLM / NemoClaw Runtime
# -----------------------------------------------------------------------------
LLM_BASE_URL=https://llm.desineuron.in
SGLANG_BASE_URL=https://llm.desineuron.in
SGLANG_CHAT_URL=https://llm.desineuron.in/v1/chat/completions
SGLANG_MODELS_URL=https://llm.desineuron.in/v1/models
SGLANG_MODEL=qwen3.6:35b-a3b
SGLANG_API_TOKEN=
RUNTIME_LLM_TIMEOUT_S=90.0
RUNTIME_LLM_BATCH_CONCURRENCY=2
NEMOCLAW_BASE_URL=https://llm.desineuron.in
NEMOCLAW_CHAT_URL=https://llm.desineuron.in/v1/chat/completions
NEMOCLAW_MODEL=qwen3.6:35b-a3b
NEMOCLAW_API_TOKEN=
NEMOCLAW_WEBHOOK_SECRET=
NEMOCLAW_PROMPT_DIR=/opt/dlami/nvme/nemoclaw/prompts
NEMOCLAW_TIMEOUT_S=45.0
NEMOCLAW_TEMPERATURE=0.2
# -----------------------------------------------------------------------------
# Oracle / Sentinel Runtime
# -----------------------------------------------------------------------------
ORACLE_DEFAULT_TENANT_ID=tenant_velocity
ORACLE_DEFAULT_TIMEZONE=Asia/Dubai
ORACLE_DEFAULT_LOCALE=en-AE
ORACLE_POLICY_PROFILE_ID=policy_sales_director_standard_v4
ORACLE_DEFAULT_PAGE_TITLE=Oracle Main Canvas
ORACLE_ALLOW_IN_MEMORY_FALLBACK=false
SENTINEL_PERCEPTION_INTERVAL_SECONDS=3
# -----------------------------------------------------------------------------
# Legacy / Adjacent Integrations
# -----------------------------------------------------------------------------
# Supabase is retained only for legacy Catalyst CRM/marketing surfaces.
SUPABASE_URL=
SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
# Ad-network integrations for Catalyst surfaces.
GOOGLE_ADS_DEVELOPER_TOKEN=
GOOGLE_ADS_CLIENT_ID=
GOOGLE_ADS_CLIENT_SECRET=
GOOGLE_ADS_REFRESH_TOKEN=
GOOGLE_ADS_CUSTOMER_ID=
LINKEDIN_ACCESS_TOKEN=
LINKEDIN_ORG_ID=
TWITTER_BEARER_TOKEN=
BRAVE_API_KEY=
# Colony orchestration service. Required for /api/colony mission dispatch.
COLONY_SERVICE_URL=
COLONY_TIMEOUT_SECONDS=30
# -----------------------------------------------------------------------------
# Observability / Alerts
# -----------------------------------------------------------------------------
SENTRY_DSN=
OTEL_EXPORTER_OTLP_ENDPOINT=
SLACK_WEBHOOK_URL=
PAGERDUTY_ROUTING_KEY=
# -----------------------------------------------------------------------------
# Fastlane / Apple Release Automation
# -----------------------------------------------------------------------------
# These are consumed from the operator Mac when running fastlane, not by the
# backend service. They are documented here so release secrets are tracked.
FASTLANE_APPLE_ID=
FASTLANE_TEAM_ID=
FASTLANE_ITC_TEAM_ID=
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD=
FASTLANE_FORCE_CERT=0
FASTLANE_FORCE_PROFILE=0
FASTLANE_SKIP_WAITING=true
FASTLANE_DISTRIBUTE_EXTERNAL=0
FASTLANE_NOTIFY_EXTERNAL_TESTERS=0
FASTLANE_CHANGELOG=
APP_STORE_CONNECT_API_KEY_KEY_ID=
APP_STORE_CONNECT_API_KEY_ISSUER_ID=
APP_STORE_CONNECT_API_KEY_KEY=

View File

@@ -25,7 +25,7 @@ from __future__ import annotations
import json
import logging
import uuid
from datetime import UTC, datetime
from datetime import datetime, timezone
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
@@ -36,6 +36,7 @@ from backend.auth.dependencies import get_current_user
logger = logging.getLogger("velocity.admin_surface")
router = APIRouter()
dashboard_router = APIRouter()
# ── RBAC guard ────────────────────────────────────────────────────────────────
@@ -61,6 +62,128 @@ def _pool(request: Request):
return pool
class OfflineReplayAuditRecord(BaseModel):
id: str
kind: str
operation: str
targetId: str | None = None
queuedAt: str
attemptCount: int
lastAttemptAt: str | None = None
lastError: str | None = None
class OfflineReplayAuditRequest(BaseModel):
records: list[OfflineReplayAuditRecord] = Field(default_factory=list)
pendingCount: int
@dashboard_router.get("/metrics", summary="Canonical dashboard metrics for WebOS and iPad parity")
async def get_dashboard_metrics(
request: Request,
user=Depends(get_current_user),
):
pool = _pool(request)
tenant_id = user.tenant_id or "tenant_velocity"
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT
(SELECT COUNT(*) FROM crm_leads WHERE tenant_id = $1)::int AS lead_count,
(
SELECT COUNT(DISTINCT p.person_id)::int
FROM crm_people p
LEFT JOIN LATERAL (
SELECT current_value
FROM intel_qd_scores q
WHERE q.person_id = p.person_id
ORDER BY q.computed_at DESC
LIMIT 1
) q ON TRUE
WHERE p.tenant_id = $1
AND (
COALESCE(p.buyer_type, '') ILIKE '%whale%'
OR COALESCE(q.current_value, 0) >= 0.90
)
) AS whale_lead_count,
(SELECT COUNT(*) FROM inventory_properties WHERE tenant_id = $1 AND status <> 'archived')::int AS property_count,
(
SELECT COUNT(*)
FROM user_calendar_events
WHERE tenant_id = $1
AND owner_user_id = $2
AND status NOT IN ('cancelled', 'done')
AND start_at >= date_trunc('day', NOW())
AND start_at < date_trunc('day', NOW()) + INTERVAL '1 day'
)::int AS today_calendar_count,
(
SELECT COUNT(*)
FROM intel_reminders
WHERE COALESCE(tenant_id, $1) = $1
AND status IN ('pending', 'open', 'scheduled', 'snoozed', 'confirmed')
)::int AS pending_task_count,
(
SELECT COUNT(*)
FROM intel_reminders
WHERE COALESCE(tenant_id, $1) = $1
AND status IN ('pending', 'open', 'scheduled', 'snoozed', 'confirmed')
AND priority IN ('urgent', 'high')
)::int AS urgent_task_count,
(SELECT COUNT(*) FROM insight_recommendations WHERE tenant_id = $1 AND status = 'pending')::int AS pending_insights,
(SELECT COUNT(*) FROM edge_transcription_jobs WHERE tenant_id = $1 AND status = 'pending')::int AS pending_transcriptions
""",
tenant_id,
user.user_id,
)
return {
"status": "ok",
"data": {
"leadCount": row["lead_count"],
"whaleLeadCount": row["whale_lead_count"],
"propertyCount": row["property_count"],
"todayCalendarCount": row["today_calendar_count"],
"pendingTaskCount": row["pending_task_count"],
"urgentTaskCount": row["urgent_task_count"],
"pendingInsights": row["pending_insights"],
"pendingTranscriptions": row["pending_transcriptions"],
},
"meta": {"generatedAt": datetime.now(timezone.utc).isoformat()},
}
@dashboard_router.post("/offline-replay/audit", summary="Publish native offline replay queue observability")
async def publish_offline_replay_audit(
body: OfflineReplayAuditRequest,
request: Request,
user=Depends(get_current_user),
):
pool = _pool(request)
async with pool.acquire() as conn:
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS mobile_offline_replay_audits (
audit_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id TEXT NOT NULL,
user_id TEXT NOT NULL,
pending_count INT NOT NULL,
records JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"""
)
await conn.execute(
"""
INSERT INTO mobile_offline_replay_audits (tenant_id, user_id, pending_count, records)
VALUES ($1, $2, $3, $4::jsonb)
""",
user.tenant_id,
user.user_id,
body.pendingCount,
json.dumps([record.model_dump() for record in body.records]),
)
return {"status": "ok", "pendingCount": body.pendingCount}
# ── Pydantic Models ───────────────────────────────────────────────────────────
VALID_ACTION_TYPES = {
@@ -131,7 +254,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 +314,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 +338,7 @@ async def get_installs(
)
return {
"installs": [dict(r) for r in rows],
"timestamp": datetime.now(UTC).isoformat(),
"timestamp": datetime.now(timezone.utc).isoformat(),
}

View File

@@ -10,6 +10,8 @@ Routes:
POST /api/catalyst/auth/meta — OAuth token acquisition
"""
from __future__ import annotations
import os
import uuid
import hashlib
@@ -17,9 +19,11 @@ import logging
from typing import Any
from datetime import datetime
from fastapi import APIRouter, HTTPException, Query, Request, status
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from pydantic import BaseModel, Field
from backend.auth.dependencies import UserPrincipal, get_current_user
from backend.services.ad_network_service import (
AdInsight,
BidStrategyUpdate,
@@ -27,6 +31,17 @@ from backend.services.ad_network_service import (
Platform,
ad_network_service,
)
from backend.services.social_posting import (
PostRequest,
PostStatus,
SocialPlatform,
SocialPostingConfigurationError,
SocialPostingError,
get_post,
list_posts,
publish_content,
publish_due_scheduled,
)
logger = logging.getLogger(__name__)
@@ -91,6 +106,13 @@ def _ok(data: Any, meta: dict | None = None) -> dict:
return {"status": "ok", "data": data, "meta": meta or {}}
def _get_db_pool(request: Request) -> Any:
pool = getattr(request.app.state, "db_pool", None)
if pool is None:
raise HTTPException(status_code=503, detail="Database unavailable.")
return pool
def _sha256_hash(value: str) -> str:
"""SHA-256 hash an email for Meta's hashed audience upload."""
return hashlib.sha256(value.strip().lower().encode()).hexdigest()
@@ -510,3 +532,91 @@ async def meta_oauth(payload: MetaAuthRequest) -> dict:
"token_type": token_data.get("token_type", "bearer"),
"expires_in": token_data.get("expires_in"),
})
# ── 6. Social publishing ─────────────────────────────────────────────────────
@router.post("/publish", status_code=status.HTTP_201_CREATED, summary="Publish or schedule content to social channels")
async def api_publish_content(
payload: PostRequest,
request: Request,
user: UserPrincipal = Depends(get_current_user),
) -> dict:
try:
result = await publish_content(
pool=_get_db_pool(request),
tenant_id=user.tenant_id,
actor_id=user.user_id,
payload=payload,
)
except SocialPostingConfigurationError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=422, detail=f"Invalid schedule_time: {exc}") from exc
except (SocialPostingError, httpx.HTTPError) as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return _ok(result)
@router.get("/posts", summary="List tenant-scoped social posts")
async def api_list_social_posts(
request: Request,
platform: SocialPlatform | None = Query(default=None),
post_status: PostStatus | None = Query(default=None, alias="status"),
limit: int = Query(default=50, ge=1, le=200),
user: UserPrincipal = Depends(get_current_user),
) -> dict:
posts = await list_posts(
pool=_get_db_pool(request),
tenant_id=user.tenant_id,
platform=platform,
status=post_status,
limit=limit,
)
return _ok(posts, meta={"count": len(posts)})
@router.get("/posts/{post_id}", summary="Get a tenant-scoped social post")
async def api_get_social_post(
post_id: str,
request: Request,
user: UserPrincipal = Depends(get_current_user),
) -> dict:
post = await get_post(pool=_get_db_pool(request), tenant_id=user.tenant_id, post_id=post_id)
if post is None:
raise HTTPException(status_code=404, detail=f"Social post '{post_id}' not found.")
return _ok(post)
@router.get("/scheduled", summary="List scheduled social posts for the authenticated tenant")
async def api_scheduled_posts(
request: Request,
limit: int = Query(default=50, ge=1, le=200),
user: UserPrincipal = Depends(get_current_user),
) -> dict:
posts = await list_posts(
pool=_get_db_pool(request),
tenant_id=user.tenant_id,
status=PostStatus.SCHEDULED,
limit=limit,
)
return _ok(posts, meta={"count": len(posts)})
@router.post("/scheduled/publish-due", summary="Publish due scheduled social posts for the authenticated tenant")
async def api_publish_due_scheduled(
request: Request,
limit: int = Query(default=20, ge=1, le=100),
user: UserPrincipal = Depends(get_current_user),
) -> dict:
try:
result = await publish_due_scheduled(
pool=_get_db_pool(request),
tenant_id=user.tenant_id,
limit=limit,
)
except SocialPostingConfigurationError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
except (SocialPostingError, httpx.HTTPError) as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return _ok(result)

View File

@@ -0,0 +1,251 @@
from __future__ import annotations
import uuid
from typing import Any, Literal
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from pydantic import BaseModel, Field
from backend.auth.dependencies import UserPrincipal, get_current_user
from backend.services.colony_gateway import ColonyConfigurationError, ColonyGateway, ColonyGatewayError
from backend.services.colony_repository import ColonyRepository
router = APIRouter()
MissionType = Literal["oracle_advisory", "crm_lead_intelligence", "catalyst_strategy_brief"]
RiskLevel = Literal["low", "medium", "high"]
SensitivityClass = Literal["public", "internal", "confidential"]
class MissionCreateRequest(BaseModel):
mission_type: MissionType
user_goal: str = Field(..., min_length=1, max_length=2000)
normalized_goal: str | None = Field(default=None, max_length=2000)
origin_surface: str = Field(default="api", min_length=1, max_length=128)
actor_role: str | None = Field(default=None, max_length=128)
risk_level: RiskLevel = "low"
sensitivity_class: SensitivityClass = "internal"
time_budget_ms: int = Field(default=30000, gt=0, le=300000)
token_budget: int = Field(default=4096, gt=0, le=200000)
context_refs: dict[str, Any] = Field(default_factory=dict)
requested_outputs: list[str] = Field(default_factory=list)
payload: dict[str, Any] = Field(default_factory=dict)
class ApprovalRequest(BaseModel):
reason: str | None = Field(default=None, max_length=2000)
def _get_repo(request: Request) -> ColonyRepository:
pool = getattr(request.app.state, "db_pool", None)
if pool is None:
raise HTTPException(status_code=503, detail="Database unavailable.")
return ColonyRepository(pool)
def _serialize_mission(row: dict[str, Any], dispatch: dict[str, Any] | None = None) -> dict[str, Any]:
data = {
"mission_id": str(row["mission_id"]),
"tenant_id": row["tenant_id"],
"mission_type": row["mission_type"],
"origin_surface": row["origin_surface"],
"actor_id": row["actor_id"],
"actor_role": row["actor_role"],
"risk_level": row["risk_level"],
"sensitivity_class": row["sensitivity_class"],
"status": row["status"],
"review_status": row["review_status"],
"time_budget_ms": row["time_budget_ms"],
"token_budget": row["token_budget"],
"user_goal": row["user_goal"],
"normalized_goal": row["normalized_goal"],
"context_refs": row["context_refs"] or {},
"requested_outputs": row["requested_outputs"] or [],
"payload": row["payload"] or {},
"created_at": row["created_at"].isoformat() if row.get("created_at") else None,
"updated_at": row["updated_at"].isoformat() if row.get("updated_at") else None,
"completed_at": row["completed_at"].isoformat() if row.get("completed_at") else None,
}
if dispatch is not None:
data["dispatch"] = dispatch
return data
@router.post("/missions", status_code=status.HTTP_201_CREATED, summary="Create and dispatch a colony mission")
async def create_mission(
body: MissionCreateRequest,
request: Request,
user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
repo = _get_repo(request)
try:
gateway = ColonyGateway()
except ColonyConfigurationError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
mission_id = str(uuid.uuid4())
mission = {
"mission_id": mission_id,
"mission_type": body.mission_type,
"origin_surface": body.origin_surface,
"tenant_id": user.tenant_id,
"actor_id": user.user_id,
"actor_role": body.actor_role or user.role,
"risk_level": body.risk_level,
"sensitivity_class": body.sensitivity_class,
"time_budget_ms": body.time_budget_ms,
"token_budget": body.token_budget,
"user_goal": body.user_goal,
"normalized_goal": body.normalized_goal or body.user_goal,
"context_refs": body.context_refs,
"requested_outputs": body.requested_outputs,
"payload": body.payload,
}
row = await repo.create_mission(mission)
await repo.log_event(
mission_id=mission_id,
tenant_id=user.tenant_id,
event_type="mission_created",
actor=user.user_id,
detail={"mission_type": body.mission_type},
)
try:
dispatch = await gateway.dispatch_mission(mission)
except ColonyGatewayError as exc:
failed = await repo.update_status(mission_id, user.tenant_id, "dispatch_failed")
await repo.log_event(
mission_id=mission_id,
tenant_id=user.tenant_id,
event_type="mission_dispatch_failed",
actor=user.user_id,
detail={"error": str(exc)},
)
raise HTTPException(
status_code=502,
detail={
"message": str(exc),
"mission": _serialize_mission(failed or row),
},
) from exc
queued = await repo.update_status(mission_id, user.tenant_id, "queued")
await repo.log_event(
mission_id=mission_id,
tenant_id=user.tenant_id,
event_type="mission_dispatched",
actor=user.user_id,
detail={"dispatch": dispatch},
)
return {"status": "ok", "data": _serialize_mission(queued or row, dispatch=dispatch)}
@router.get("/missions", summary="List colony missions for the authenticated tenant")
async def list_missions(
request: Request,
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
rows = await _get_repo(request).list_missions(user.tenant_id, limit=limit, offset=offset)
return {"status": "ok", "data": [_serialize_mission(row) for row in rows], "meta": {"count": len(rows)}}
@router.get("/missions/{mission_id}", summary="Get a colony mission")
async def get_mission(
mission_id: str,
request: Request,
user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
row = await _get_repo(request).get_mission(mission_id, user.tenant_id)
if row is None:
raise HTTPException(status_code=404, detail=f"Mission '{mission_id}' not found.")
return {"status": "ok", "data": _serialize_mission(row)}
@router.get("/missions/{mission_id}/artifacts", summary="Get mission tasks, results, and writeback proposals")
async def get_artifacts(
mission_id: str,
request: Request,
user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
artifacts = await _get_repo(request).artifacts(mission_id, user.tenant_id)
if not artifacts:
raise HTTPException(status_code=404, detail=f"Mission '{mission_id}' not found.")
return {"status": "ok", "data": artifacts}
@router.post("/missions/{mission_id}/approve", summary="Approve all pending writeback proposals for a mission")
async def approve_writebacks(
mission_id: str,
body: ApprovalRequest,
request: Request,
user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
del body
repo = _get_repo(request)
if await repo.get_mission(mission_id, user.tenant_id) is None:
raise HTTPException(status_code=404, detail=f"Mission '{mission_id}' not found.")
count = await repo.approve_pending_writebacks(mission_id, user.tenant_id, user.user_id)
if count == 0:
raise HTTPException(status_code=404, detail="No pending writeback proposals.")
await repo.log_event(
mission_id=mission_id,
tenant_id=user.tenant_id,
event_type="writeback_approved",
actor=user.user_id,
detail={"approved": count},
)
return {"status": "ok", "data": {"approved": count}}
@router.post("/missions/{mission_id}/reject", summary="Reject all pending writeback proposals for a mission")
async def reject_writebacks(
mission_id: str,
body: ApprovalRequest,
request: Request,
user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
repo = _get_repo(request)
if await repo.get_mission(mission_id, user.tenant_id) is None:
raise HTTPException(status_code=404, detail=f"Mission '{mission_id}' not found.")
count = await repo.reject_pending_writebacks(
mission_id,
user.tenant_id,
user.user_id,
body.reason or "Rejected by operator.",
)
if count == 0:
raise HTTPException(status_code=404, detail="No pending writeback proposals.")
await repo.log_event(
mission_id=mission_id,
tenant_id=user.tenant_id,
event_type="writeback_rejected",
actor=user.user_id,
detail={"rejected": count, "reason": body.reason},
)
return {"status": "ok", "data": {"rejected": count}}
@router.get("/health", summary="Check colony root persistence and orchestrator connectivity")
async def colony_health(
request: Request,
user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
repo = _get_repo(request)
try:
gateway = ColonyGateway()
service = await gateway.health()
except (ColonyConfigurationError, ColonyGatewayError, httpx.HTTPError) as exc: # type: ignore[name-defined]
raise HTTPException(status_code=503, detail=str(exc)) from exc
rows = await repo.list_missions(user.tenant_id, limit=1, offset=0)
return {
"status": "ok",
"data": {
"tenant_id": user.tenant_id,
"root_db": "connected",
"orchestrator": service,
"has_missions": bool(rows),
},
}

841
backend/api/routes_comms.py Normal file
View File

@@ -0,0 +1,841 @@
"""
Velocity Conversations API.
Native WhatsApp-first communications surface for Velocity WebOS. The routes are
provider-abstracted and CRM-aware, while remaining safe to run in mock mode.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import os
from datetime import datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from backend.auth.dependencies import UserPrincipal, get_current_user
from backend.services.comms_evolution_provider import EvolutionProvider
from backend.services.comms_ingest import TranscriptionError, ingest_inbound_message, transcribe_recording as run_transcription
from backend.services.comms_provider import MockProvider
from backend.services.comms_waha_provider import WahaProvider
router = APIRouter()
_SCHEMA_READY = False
class SendMessageBody(BaseModel):
messageType: str = "text"
body: str
mediaUrl: str | None = None
templateName: str | None = None
templateLanguage: str | None = None
class LinkPersonBody(BaseModel):
personId: str
class NoteBody(BaseModel):
content: str
class TaskBody(BaseModel):
title: str
dueAt: str | None = None
notes: str | None = None
priority: str = "normal"
class SettingsPatch(BaseModel):
provider: str | None = None
providerBaseUrl: str | None = None
providerApiKey: str | None = None
instanceId: str | None = None
phoneNumberId: str | None = None
webhookCallbackUrl: str | None = None
webhookSecret: str | None = None
defaultAssignmentUserId: str | None = None
autoLinkByPhone: bool | None = None
createCrmInteractionOnInbound: bool | None = None
defaultCountryCode: str | None = None
transcriptionProvider: str | None = None
class TranscribeBody(BaseModel):
callId: str | None = None
recordingUrl: str | None = None
def _get_provider():
return _provider_from_config({})
def _provider_from_config(config: dict[str, Any], provider_override: str | None = None):
provider = (provider_override or config.get("provider") or os.getenv("COMMS_PROVIDER", "mock")).strip().lower()
base_url = (config.get("provider_base_url") or os.getenv("COMMS_PROVIDER_BASE_URL", "")).strip()
api_key = (config.get("provider_api_key") or os.getenv("COMMS_PROVIDER_API_KEY", "")).strip()
instance_id = (config.get("instance_id") or os.getenv("COMMS_INSTANCE_ID", "")).strip() or None
if provider == "waha":
return WahaProvider(base_url, api_key, instance_id)
if provider == "evolution":
return EvolutionProvider(base_url, api_key, instance_id)
return MockProvider("", "", "mock")
async def _load_config(pool) -> dict[str, Any]:
async with pool.acquire() as conn:
row = await conn.fetchrow("SELECT value_json FROM comms_settings WHERE key = 'config'")
return _json_obj(row["value_json"]) if row else {}
async def _get_provider_for_pool(pool, provider_override: str | None = None):
return _provider_from_config(await _load_config(pool), provider_override)
def _camel_settings(config: dict[str, Any], updated_at: datetime | None = None) -> dict[str, Any]:
return {
"provider": config.get("provider", os.getenv("COMMS_PROVIDER", "mock")),
"providerBaseUrl": config.get("provider_base_url", os.getenv("COMMS_PROVIDER_BASE_URL", "")),
"providerApiKey": config.get("provider_api_key", ""),
"instanceId": config.get("instance_id", os.getenv("COMMS_INSTANCE_ID", "")),
"phoneNumberId": config.get("phone_number_id", ""),
"webhookCallbackUrl": config.get("webhook_callback_url", "/api/comms/webhooks/{provider}"),
"webhookSecretSet": bool(config.get("webhook_secret_hash") or config.get("webhook_secret_set")),
"defaultAssignmentUserId": config.get("default_assignment_user_id"),
"autoLinkByPhone": bool(config.get("auto_link_by_phone", True)),
"createCrmInteractionOnInbound": bool(config.get("create_crm_interaction_on_inbound", True)),
"defaultCountryCode": str(config.get("default_country_code", os.getenv("COMMS_DEFAULT_COUNTRY_CODE", "91"))),
"mediaStorageDir": config.get("media_storage_dir", os.getenv("COMMS_MEDIA_STORAGE_DIR", "/opt/dlami/nvme/assets/comms")),
"transcriptionProvider": config.get("transcription_provider", os.getenv("COMMS_TRANSCRIPTION_PROVIDER", "none")),
**({"updatedAt": updated_at.isoformat()} if updated_at else {}),
}
def _snake_settings(body: SettingsPatch) -> dict[str, Any]:
mapping = {
"provider": "provider",
"providerBaseUrl": "provider_base_url",
"providerApiKey": "provider_api_key",
"instanceId": "instance_id",
"phoneNumberId": "phone_number_id",
"webhookCallbackUrl": "webhook_callback_url",
"defaultAssignmentUserId": "default_assignment_user_id",
"autoLinkByPhone": "auto_link_by_phone",
"createCrmInteractionOnInbound": "create_crm_interaction_on_inbound",
"defaultCountryCode": "default_country_code",
"transcriptionProvider": "transcription_provider",
}
raw = body.model_dump(exclude_unset=True)
updates: dict[str, Any] = {}
for src, dst in mapping.items():
if src in raw:
updates[dst] = raw[src]
if body.webhookSecret is not None:
updates["webhook_secret_hash"] = hashlib.sha256(body.webhookSecret.encode()).hexdigest() if body.webhookSecret else ""
updates["webhook_secret_set"] = bool(body.webhookSecret)
return updates
def _json_obj(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
return value
if isinstance(value, str) and value.strip():
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, dict) else {}
except json.JSONDecodeError:
return {}
return {}
def _record_value(row: Any, key: str, default: Any = None) -> Any:
try:
return row[key]
except (KeyError, IndexError, TypeError):
return default
def _optional_datetime(value: str | None) -> datetime | None:
if not value or not value.strip():
return None
normalized = value.strip().replace("Z", "+00:00")
try:
return datetime.fromisoformat(normalized)
except ValueError as exc:
raise HTTPException(status_code=422, detail="dueAt must be an ISO-8601 timestamp.") from exc
async def _thread_context(conn, thread_id: str, tenant_id: str):
thread = await conn.fetchrow("SELECT * FROM comms_threads WHERE thread_id = $1::uuid", thread_id)
if not thread:
raise HTTPException(status_code=404, detail="Thread not found")
lead_id = None
if thread["person_id"]:
lead_id = await conn.fetchval(
"""
SELECT lead_id
FROM crm_leads
WHERE person_id = $1::uuid
AND tenant_id = $2
ORDER BY updated_at DESC NULLS LAST, created_at DESC NULLS LAST
LIMIT 1
""",
thread["person_id"],
tenant_id,
)
return thread, lead_id
async def _ensure_schema(pool) -> None:
global _SCHEMA_READY
if _SCHEMA_READY:
return
async with pool.acquire() as conn:
await conn.execute(
"""
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS comms_threads (
thread_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
provider TEXT NOT NULL DEFAULT 'mock',
external_thread_id TEXT,
person_id UUID NULL REFERENCES crm_people(person_id) ON DELETE SET NULL,
phone_e164 TEXT NOT NULL,
display_name TEXT,
channel TEXT NOT NULL DEFAULT 'whatsapp',
status TEXT NOT NULL DEFAULT 'open',
assigned_user_id UUID NULL,
last_message_at TIMESTAMPTZ,
unread_count INT NOT NULL DEFAULT 0,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE comms_threads ADD COLUMN IF NOT EXISTS provider TEXT NOT NULL DEFAULT 'mock';
ALTER TABLE comms_threads ADD COLUMN IF NOT EXISTS external_thread_id TEXT;
ALTER TABLE comms_threads ADD COLUMN IF NOT EXISTS person_id UUID NULL REFERENCES crm_people(person_id) ON DELETE SET NULL;
ALTER TABLE comms_threads ADD COLUMN IF NOT EXISTS phone_e164 TEXT NOT NULL DEFAULT '';
ALTER TABLE comms_threads ADD COLUMN IF NOT EXISTS display_name TEXT;
ALTER TABLE comms_threads ADD COLUMN IF NOT EXISTS channel TEXT NOT NULL DEFAULT 'whatsapp';
ALTER TABLE comms_threads ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open';
ALTER TABLE comms_threads ADD COLUMN IF NOT EXISTS assigned_user_id UUID NULL;
ALTER TABLE comms_threads ADD COLUMN IF NOT EXISTS last_message_at TIMESTAMPTZ;
ALTER TABLE comms_threads ADD COLUMN IF NOT EXISTS unread_count INT NOT NULL DEFAULT 0;
ALTER TABLE comms_threads ADD COLUMN IF NOT EXISTS metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb;
ALTER TABLE comms_threads ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
ALTER TABLE comms_threads ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
CREATE INDEX IF NOT EXISTS idx_comms_threads_phone_provider ON comms_threads(provider, phone_e164);
CREATE INDEX IF NOT EXISTS idx_comms_threads_person ON comms_threads(person_id) WHERE person_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_comms_threads_status ON comms_threads(status);
CREATE INDEX IF NOT EXISTS idx_comms_threads_last_message ON comms_threads(last_message_at DESC NULLS LAST);
CREATE TABLE IF NOT EXISTS comms_messages (
message_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
thread_id UUID NOT NULL REFERENCES comms_threads(thread_id) ON DELETE CASCADE,
provider TEXT NOT NULL DEFAULT 'mock',
external_message_id TEXT,
direction TEXT NOT NULL CHECK (direction IN ('inbound', 'outbound', 'system')),
message_type TEXT NOT NULL DEFAULT 'text',
body TEXT NOT NULL DEFAULT '',
media_url TEXT,
media_mime_type TEXT,
delivery_status TEXT NOT NULL DEFAULT 'pending',
sent_at TIMESTAMPTZ,
delivered_at TIMESTAMPTZ,
read_at TIMESTAMPTZ,
raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE comms_messages ADD COLUMN IF NOT EXISTS provider TEXT NOT NULL DEFAULT 'mock';
ALTER TABLE comms_messages ADD COLUMN IF NOT EXISTS external_message_id TEXT;
ALTER TABLE comms_messages ADD COLUMN IF NOT EXISTS direction TEXT NOT NULL DEFAULT 'system';
ALTER TABLE comms_messages ADD COLUMN IF NOT EXISTS message_type TEXT NOT NULL DEFAULT 'text';
ALTER TABLE comms_messages ADD COLUMN IF NOT EXISTS body TEXT NOT NULL DEFAULT '';
ALTER TABLE comms_messages ADD COLUMN IF NOT EXISTS media_url TEXT;
ALTER TABLE comms_messages ADD COLUMN IF NOT EXISTS media_mime_type TEXT;
ALTER TABLE comms_messages ADD COLUMN IF NOT EXISTS delivery_status TEXT NOT NULL DEFAULT 'pending';
ALTER TABLE comms_messages ADD COLUMN IF NOT EXISTS sent_at TIMESTAMPTZ;
ALTER TABLE comms_messages ADD COLUMN IF NOT EXISTS delivered_at TIMESTAMPTZ;
ALTER TABLE comms_messages ADD COLUMN IF NOT EXISTS read_at TIMESTAMPTZ;
ALTER TABLE comms_messages ADD COLUMN IF NOT EXISTS raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb;
ALTER TABLE comms_messages ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
CREATE INDEX IF NOT EXISTS idx_comms_messages_thread ON comms_messages(thread_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_comms_messages_external ON comms_messages(external_message_id) WHERE external_message_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS comms_call_logs (
call_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
thread_id UUID NULL REFERENCES comms_threads(thread_id) ON DELETE SET NULL,
person_id UUID NULL REFERENCES crm_people(person_id) ON DELETE SET NULL,
provider TEXT NOT NULL DEFAULT 'mock',
external_call_id TEXT,
phone_e164 TEXT NOT NULL,
direction TEXT NOT NULL CHECK (direction IN ('inbound', 'outbound')),
status TEXT NOT NULL DEFAULT 'completed',
started_at TIMESTAMPTZ NOT NULL,
ended_at TIMESTAMPTZ,
duration_seconds INT,
recording_url TEXT,
transcript_id UUID,
transcript_text TEXT,
raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE comms_call_logs ADD COLUMN IF NOT EXISTS thread_id UUID NULL REFERENCES comms_threads(thread_id) ON DELETE SET NULL;
ALTER TABLE comms_call_logs ADD COLUMN IF NOT EXISTS person_id UUID NULL REFERENCES crm_people(person_id) ON DELETE SET NULL;
ALTER TABLE comms_call_logs ADD COLUMN IF NOT EXISTS provider TEXT NOT NULL DEFAULT 'mock';
ALTER TABLE comms_call_logs ADD COLUMN IF NOT EXISTS external_call_id TEXT;
ALTER TABLE comms_call_logs ADD COLUMN IF NOT EXISTS phone_e164 TEXT NOT NULL DEFAULT '';
ALTER TABLE comms_call_logs ADD COLUMN IF NOT EXISTS direction TEXT NOT NULL DEFAULT 'inbound';
ALTER TABLE comms_call_logs ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'completed';
ALTER TABLE comms_call_logs ADD COLUMN IF NOT EXISTS started_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
ALTER TABLE comms_call_logs ADD COLUMN IF NOT EXISTS ended_at TIMESTAMPTZ;
ALTER TABLE comms_call_logs ADD COLUMN IF NOT EXISTS duration_seconds INT;
ALTER TABLE comms_call_logs ADD COLUMN IF NOT EXISTS recording_url TEXT;
ALTER TABLE comms_call_logs ADD COLUMN IF NOT EXISTS transcript_id UUID;
ALTER TABLE comms_call_logs ADD COLUMN IF NOT EXISTS transcript_text TEXT;
ALTER TABLE comms_call_logs ADD COLUMN IF NOT EXISTS raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb;
ALTER TABLE comms_call_logs ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
CREATE INDEX IF NOT EXISTS idx_comms_call_logs_phone ON comms_call_logs(phone_e164);
CREATE INDEX IF NOT EXISTS idx_comms_call_logs_thread ON comms_call_logs(thread_id) WHERE thread_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS comms_settings (
key TEXT PRIMARY KEY,
value_json JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO comms_settings (key, value_json)
VALUES ('config', '{"provider":"mock","auto_link_by_phone":true,"create_crm_interaction_on_inbound":true,"default_country_code":"91","transcription_provider":"none"}'::jsonb)
ON CONFLICT (key) DO NOTHING;
"""
)
_SCHEMA_READY = True
async def _pool(request: Request):
pool = request.app.state.db_pool
if pool is None:
raise HTTPException(status_code=503, detail="Database unavailable")
await _ensure_schema(pool)
return pool
def _row_thread(row) -> dict[str, Any]:
return {
"threadId": str(row["thread_id"]),
"provider": row["provider"],
"externalThreadId": row["external_thread_id"],
"personId": str(row["person_id"]) if row["person_id"] else None,
"phoneE164": row["phone_e164"],
"displayName": row["display_name"],
"channel": row["channel"],
"status": row["status"],
"assignedUserId": str(row["assigned_user_id"]) if row["assigned_user_id"] else None,
"lastMessageAt": row["last_message_at"].isoformat() if row["last_message_at"] else None,
"unreadCount": row["unread_count"],
"metadataJson": _json_obj(row["metadata_json"]),
"createdAt": row["created_at"].isoformat(),
"updatedAt": row["updated_at"].isoformat(),
"lastMessagePreview": _record_value(row, "last_message_preview"),
"crmPerson": {
"id": str(row["person_id"]),
"fullName": row["crm_full_name"],
"primaryPhone": row["crm_primary_phone"],
"primaryEmail": row["crm_primary_email"],
"buyerType": row["crm_buyer_type"],
"leadStatus": row["crm_lead_status"],
"projectName": row["crm_project_name"],
} if row["person_id"] else None,
}
@router.get("/threads")
async def list_threads(
request: Request,
status: str | None = None,
search: str | None = None,
limit: int = 50,
offset: int = 0,
_: UserPrincipal = Depends(get_current_user),
):
pool = await _pool(request)
limit = max(1, min(limit, 100))
offset = max(0, offset)
conditions = ["1=1"]
values: list[Any] = []
if status:
values.append(status)
conditions.append(f"t.status = ${len(values)}")
if search:
values.append(f"%{search}%")
conditions.append(f"(t.phone_e164 ILIKE ${len(values)} OR t.display_name ILIKE ${len(values)} OR p.full_name ILIKE ${len(values)} OR p.primary_email ILIKE ${len(values)})")
where_clause = " AND ".join(conditions)
async with pool.acquire() as conn:
rows = await conn.fetch(
f"""
SELECT t.*,
p.full_name AS crm_full_name,
p.primary_email AS crm_primary_email,
p.primary_phone AS crm_primary_phone,
p.buyer_type AS crm_buyer_type,
COALESCE(l.status, '') AS crm_lead_status,
(
SELECT pi.project_name FROM crm_property_interests pi
WHERE pi.person_id = p.person_id
ORDER BY pi.priority ASC NULLS LAST, pi.created_at DESC NULLS LAST
LIMIT 1
) AS crm_project_name,
(
SELECT m.body FROM comms_messages m
WHERE m.thread_id = t.thread_id
ORDER BY m.created_at DESC
LIMIT 1
) AS last_message_preview
FROM comms_threads t
LEFT JOIN crm_people p ON t.person_id = p.person_id
LEFT JOIN LATERAL (
SELECT status FROM crm_leads l
WHERE l.person_id = p.person_id
ORDER BY l.updated_at DESC NULLS LAST
LIMIT 1
) l ON TRUE
WHERE {where_clause}
ORDER BY t.last_message_at DESC NULLS LAST, t.updated_at DESC
LIMIT ${len(values)+1} OFFSET ${len(values)+2}
""",
*values,
limit,
offset,
)
total = await conn.fetchval(
f"SELECT COUNT(*) FROM comms_threads t LEFT JOIN crm_people p ON t.person_id = p.person_id WHERE {where_clause}",
*values,
)
unread = await conn.fetchval("SELECT COALESCE(SUM(unread_count),0)::int FROM comms_threads WHERE status = 'open'")
return {"threads": [_row_thread(row) for row in rows], "total": total or 0, "unreadTotal": unread or 0}
@router.get("/threads/{thread_id}")
async def get_thread(thread_id: str, request: Request, _: UserPrincipal = Depends(get_current_user)):
pool = await _pool(request)
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT t.*, p.full_name AS crm_full_name, p.primary_email AS crm_primary_email,
p.primary_phone AS crm_primary_phone, p.buyer_type AS crm_buyer_type,
COALESCE(l.status, '') AS crm_lead_status,
(
SELECT pi.project_name FROM crm_property_interests pi
WHERE pi.person_id = p.person_id
ORDER BY pi.priority ASC NULLS LAST, pi.created_at DESC NULLS LAST
LIMIT 1
) AS crm_project_name,
NULL::text AS last_message_preview
FROM comms_threads t
LEFT JOIN crm_people p ON t.person_id = p.person_id
LEFT JOIN LATERAL (
SELECT status FROM crm_leads l
WHERE l.person_id = p.person_id
ORDER BY l.updated_at DESC NULLS LAST
LIMIT 1
) l ON TRUE
WHERE t.thread_id = $1::uuid
""",
thread_id,
)
if not row:
raise HTTPException(status_code=404, detail="Thread not found")
return _row_thread(row)
@router.get("/threads/{thread_id}/messages")
async def list_messages(
thread_id: str,
request: Request,
limit: int = 100,
offset: int = 0,
_: UserPrincipal = Depends(get_current_user),
):
pool = await _pool(request)
limit = max(1, min(limit, 200))
offset = max(0, offset)
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT * FROM comms_messages
WHERE thread_id = $1::uuid
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
""",
thread_id,
limit,
offset,
)
messages = [
{
"messageId": str(row["message_id"]),
"threadId": str(row["thread_id"]),
"provider": row["provider"],
"externalMessageId": row["external_message_id"],
"direction": row["direction"],
"messageType": row["message_type"],
"body": row["body"],
"mediaUrl": row["media_url"],
"mediaMimeType": row["media_mime_type"],
"deliveryStatus": row["delivery_status"],
"sentAt": row["sent_at"].isoformat() if row["sent_at"] else None,
"deliveredAt": row["delivered_at"].isoformat() if row["delivered_at"] else None,
"readAt": row["read_at"].isoformat() if row["read_at"] else None,
"rawPayload": _json_obj(row["raw_payload"]),
"createdAt": row["created_at"].isoformat(),
}
for row in reversed(rows)
]
return {"messages": messages, "thread": await get_thread(thread_id, request)}
@router.get("/threads/{thread_id}/calls")
async def list_thread_calls(
thread_id: str,
request: Request,
limit: int = 25,
offset: int = 0,
_: UserPrincipal = Depends(get_current_user),
):
pool = await _pool(request)
limit = max(1, min(limit, 100))
offset = max(0, offset)
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT *
FROM comms_call_logs
WHERE thread_id = $1::uuid
ORDER BY started_at DESC, created_at DESC
LIMIT $2 OFFSET $3
""",
thread_id,
limit,
offset,
)
calls = [
{
"callId": str(row["call_id"]),
"threadId": str(row["thread_id"]) if row["thread_id"] else None,
"personId": str(row["person_id"]) if row["person_id"] else None,
"provider": row["provider"],
"externalCallId": row["external_call_id"],
"phoneE164": row["phone_e164"],
"direction": row["direction"],
"status": row["status"],
"startedAt": row["started_at"].isoformat(),
"endedAt": row["ended_at"].isoformat() if row["ended_at"] else None,
"durationSeconds": row["duration_seconds"],
"recordingUrl": row["recording_url"],
"transcriptId": str(row["transcript_id"]) if row["transcript_id"] else None,
"transcriptText": row["transcript_text"],
"rawPayload": _json_obj(row["raw_payload"]),
"createdAt": row["created_at"].isoformat(),
}
for row in rows
]
return {"calls": calls, "thread": await get_thread(thread_id, request)}
@router.post("/threads/{thread_id}/messages")
async def send_message(
thread_id: str,
body: SendMessageBody,
request: Request,
_: UserPrincipal = Depends(get_current_user),
):
pool = await _pool(request)
async with pool.acquire() as conn:
thread = await conn.fetchrow("SELECT * FROM comms_threads WHERE thread_id = $1::uuid", thread_id)
if not thread:
raise HTTPException(status_code=404, detail="Thread not found")
provider = await _get_provider_for_pool(pool)
result = await provider.send_message(
phone=thread["phone_e164"],
message=body.body,
message_type=body.messageType,
media_url=body.mediaUrl,
)
async with pool.acquire() as conn:
msg_id = await conn.fetchval(
"""
INSERT INTO comms_messages
(thread_id, provider, external_message_id, direction, message_type, body, media_url, delivery_status, sent_at)
VALUES ($1::uuid, $2, $3, 'outbound', $4, $5, $6, 'sent', NOW())
RETURNING message_id
""",
thread_id,
os.getenv("COMMS_PROVIDER", "mock").lower(),
result.get("external_message_id"),
body.messageType,
body.body,
body.mediaUrl,
)
await conn.execute("UPDATE comms_threads SET last_message_at = NOW(), updated_at = NOW() WHERE thread_id = $1::uuid", thread_id)
return {"messageId": str(msg_id), "providerResult": result}
@router.post("/threads/{thread_id}/link-person")
async def link_person(
thread_id: str,
body: LinkPersonBody,
request: Request,
user: UserPrincipal = Depends(get_current_user),
):
pool = await _pool(request)
async with pool.acquire() as conn:
exists = await conn.fetchval(
"SELECT EXISTS (SELECT 1 FROM crm_people WHERE person_id = $1::uuid AND tenant_id = $2)",
body.personId,
user.tenant_id,
)
if not exists:
raise HTTPException(status_code=404, detail="CRM person not found")
updated = await conn.execute(
"UPDATE comms_threads SET person_id = $1::uuid, updated_at = NOW() WHERE thread_id = $2::uuid",
body.personId,
thread_id,
)
return {"success": updated.endswith("1"), "threadId": thread_id, "personId": body.personId}
@router.post("/threads/{thread_id}/notes")
async def add_note(thread_id: str, body: NoteBody, request: Request, user: UserPrincipal = Depends(get_current_user)):
pool = await _pool(request)
async with pool.acquire() as conn:
async with conn.transaction():
thread, lead_id = await _thread_context(conn, thread_id, user.tenant_id)
msg_id = await conn.fetchval(
"""
INSERT INTO comms_messages (thread_id, provider, direction, message_type, body, delivery_status)
VALUES ($1::uuid, 'system', 'system', 'text', $2, 'delivered')
RETURNING message_id
""",
thread_id,
f"Note: {body.content}",
)
interaction_id = None
canonical_message_id = None
if thread["person_id"]:
interaction_id = await conn.fetchval(
"""
INSERT INTO intel_interactions (
interaction_id, tenant_id, person_id, lead_id, channel,
interaction_type, happened_at, summary, source_ref, metadata_json
) VALUES (
gen_random_uuid(), COALESCE($1, 'tenant_velocity'), $2::uuid, $3::uuid, 'whatsapp',
'operator_note', NOW(), $4, $5, $6::jsonb
)
RETURNING interaction_id
""",
user.tenant_id,
thread["person_id"],
lead_id,
body.content,
f"comms:{thread_id}",
json.dumps({"source": "comms_thread_note", "thread_id": thread_id, "message_id": str(msg_id)}),
)
canonical_message_id = await conn.fetchval(
"""
INSERT INTO intel_messages (
message_id, interaction_id, thread_id, sender_role, sender_name,
message_text, delivered_at, metadata_json
) VALUES (
gen_random_uuid(), $1::uuid, $2::uuid, 'operator', 'iPad operator',
$3, NOW(), $4::jsonb
)
RETURNING message_id
""",
interaction_id,
thread_id,
body.content,
json.dumps({"source": "comms_thread_note"}),
)
return {
"messageId": str(msg_id),
"canonicalInteractionId": str(interaction_id) if interaction_id else None,
"canonicalMessageId": str(canonical_message_id) if canonical_message_id else None,
}
@router.post("/threads/{thread_id}/tasks")
async def add_task(thread_id: str, body: TaskBody, request: Request, user: UserPrincipal = Depends(get_current_user)):
pool = await _pool(request)
text = f"Task: {body.title}" + (f" (Due: {body.dueAt})" if body.dueAt else "")
async with pool.acquire() as conn:
async with conn.transaction():
thread, lead_id = await _thread_context(conn, thread_id, user.tenant_id)
msg_id = await conn.fetchval(
"""
INSERT INTO comms_messages (thread_id, provider, direction, message_type, body, delivery_status)
VALUES ($1::uuid, 'system', 'system', 'text', $2, 'delivered')
RETURNING message_id
""",
thread_id,
text,
)
reminder_id = None
interaction_id = None
if thread["person_id"]:
interaction_id = await conn.fetchval(
"""
INSERT INTO intel_interactions (
interaction_id, tenant_id, person_id, lead_id, channel,
interaction_type, happened_at, summary, source_ref, metadata_json
) VALUES (
gen_random_uuid(), COALESCE($1, 'tenant_velocity'), $2::uuid, $3::uuid, 'whatsapp',
'next_best_action', NOW(), $4, $5, $6::jsonb
)
RETURNING interaction_id
""",
user.tenant_id,
thread["person_id"],
lead_id,
body.title,
f"comms:{thread_id}",
json.dumps({"source": "comms_thread_task", "thread_id": thread_id, "message_id": str(msg_id)}),
)
reminder_id = await conn.fetchval(
"""
INSERT INTO intel_reminders (
reminder_id, tenant_id, person_id, lead_id, interaction_id,
reminder_type, title, notes, due_at, status, priority,
created_by_type, created_at
) VALUES (
gen_random_uuid(), COALESCE($1, 'tenant_velocity'), $2::uuid, $3::uuid, $4::uuid,
'follow_up', $5, $6, $7, 'pending', $8, 'human', NOW()
)
RETURNING reminder_id
""",
user.tenant_id,
thread["person_id"],
lead_id,
interaction_id,
body.title,
body.notes,
_optional_datetime(body.dueAt),
body.priority,
)
return {
"messageId": str(msg_id),
"canonicalInteractionId": str(interaction_id) if interaction_id else None,
"canonicalReminderId": str(reminder_id) if reminder_id else None,
}
@router.post("/webhooks/{provider}")
async def receive_webhook(provider: str, request: Request):
pool = await _pool(request)
raw_body = await request.body()
secret = os.getenv("COMMS_WEBHOOK_SECRET", "").strip()
if secret:
signature = request.headers.get("x-velocity-signature", "")
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature, expected):
raise HTTPException(status_code=401, detail="Invalid comms webhook signature")
payload = await request.json()
provider_impl = await _get_provider_for_pool(pool, provider)
normalized = await provider_impl.normalize_webhook(payload)
normalized["provider"] = provider
return {"received": True, "ingest": await ingest_inbound_message(pool, normalized)}
@router.get("/settings")
async def get_settings(request: Request, _: UserPrincipal = Depends(get_current_user)):
pool = await _pool(request)
async with pool.acquire() as conn:
row = await conn.fetchrow("SELECT value_json, updated_at FROM comms_settings WHERE key = 'config'")
config = _json_obj(row["value_json"]) if row else {}
result = _camel_settings(config, row["updated_at"] if row else None)
if result.get("providerApiKey"):
result["providerApiKey"] = "********" + str(result["providerApiKey"])[-4:]
return result
@router.patch("/settings")
async def patch_settings(body: SettingsPatch, request: Request, _: UserPrincipal = Depends(get_current_user)):
pool = await _pool(request)
updates = _snake_settings(body)
if updates.get("provider_api_key", "").startswith("*"):
updates.pop("provider_api_key", None)
async with pool.acquire() as conn:
row = await conn.fetchrow("SELECT value_json FROM comms_settings WHERE key = 'config'")
config = _json_obj(row["value_json"]) if row else {}
config.update(updates)
await conn.execute(
"""
INSERT INTO comms_settings (key, value_json, updated_at)
VALUES ('config', $1::jsonb, NOW())
ON CONFLICT (key) DO UPDATE SET value_json = EXCLUDED.value_json, updated_at = NOW()
""",
json.dumps(config),
)
return {"success": True}
@router.post("/provider/test")
async def test_provider(request: Request, _: UserPrincipal = Depends(get_current_user)):
pool = await _pool(request)
return await (await _get_provider_for_pool(pool)).test_connection()
@router.post("/recordings/transcribe")
async def transcribe_recording(body: TranscribeBody, request: Request, _: UserPrincipal = Depends(get_current_user)):
pool = await _pool(request)
config = await _load_config(pool)
configured_provider = str(config.get("transcription_provider") or "").strip().lower()
env_provider = os.getenv("COMMS_TRANSCRIPTION_PROVIDER", "none").strip().lower()
provider = env_provider if configured_provider in {"", "none", "disabled"} else configured_provider
recording_url = body.recordingUrl
if body.callId and not recording_url:
async with pool.acquire() as conn:
recording_url = await conn.fetchval(
"SELECT recording_url FROM comms_call_logs WHERE call_id = $1::uuid",
body.callId,
)
if not recording_url:
raise HTTPException(status_code=422, detail="recordingUrl is required when callId has no stored recording_url.")
try:
result = await run_transcription(recording_url, provider=provider)
except TranscriptionError as exc:
if body.callId:
async with pool.acquire() as conn:
await conn.execute(
"UPDATE comms_call_logs SET transcript_text = $1 WHERE call_id = $2::uuid",
f"Transcription failed: {exc}",
body.callId,
)
raise HTTPException(status_code=503, detail=str(exc)) from exc
if body.callId:
async with pool.acquire() as conn:
await conn.execute(
"""
UPDATE comms_call_logs
SET transcript_text = $1,
raw_payload = COALESCE(raw_payload, '{}'::jsonb) || $2::jsonb
WHERE call_id = $3::uuid
""",
result["text"],
json.dumps({"transcription": {"provider": result["provider"], "language": result["language"]}}),
body.callId,
)
return {
"success": True,
"status": "completed",
"message": "Transcription completed.",
"callId": body.callId,
"recordingUrl": body.recordingUrl,
"provider": result["provider"],
"language": result["language"],
"text": result["text"],
"segments": result["segments"],
}

File diff suppressed because it is too large Load Diff

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