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

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

103
backend/observability.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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