Merge Conflicts (#41)
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:
@@ -25,7 +25,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
@@ -131,7 +131,7 @@ async def get_health(
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"database": {
|
||||
"connected": True,
|
||||
"latency_ms": db_latency_ms,
|
||||
@@ -191,7 +191,7 @@ async def get_queues(
|
||||
"synthetic_jobs": {r["status"]: r["count"] for r in synthetic_queue},
|
||||
"inventory_batches": {r["status"]: r["count"] for r in inventory_queue},
|
||||
"admin_actions": {r["status"]: r["count"] for r in admin_queue},
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@@ -215,7 +215,7 @@ async def get_installs(
|
||||
)
|
||||
return {
|
||||
"installs": [dict(r) for r in rows],
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
@@ -43,6 +43,10 @@ def _pool(request: Request):
|
||||
return pool
|
||||
|
||||
|
||||
def _tenant_scope(user) -> str:
|
||||
return user.tenant_id
|
||||
|
||||
|
||||
# ── Pydantic Models ───────────────────────────────────────────────────────────
|
||||
|
||||
VALID_SOURCE_TYPES = {"csv", "json", "api_push", "manual"}
|
||||
@@ -111,7 +115,7 @@ async def create_import_batch(
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING batch_id, status, created_at
|
||||
""",
|
||||
user.role, body.source_type, user.user_id, body.total_rows, body.source_file_ref,
|
||||
_tenant_scope(user), body.source_type, user.user_id, body.total_rows, body.source_file_ref,
|
||||
)
|
||||
return dict(row)
|
||||
|
||||
@@ -134,10 +138,10 @@ async def list_import_batches(
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
""",
|
||||
user.role, limit, offset,
|
||||
_tenant_scope(user), limit, offset,
|
||||
)
|
||||
total = await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM inventory_import_batches WHERE tenant_id=$1", user.role,
|
||||
"SELECT COUNT(*) FROM inventory_import_batches WHERE tenant_id=$1", _tenant_scope(user),
|
||||
)
|
||||
return {"total": total, "limit": limit, "offset": offset, "batches": [dict(r) for r in rows]}
|
||||
|
||||
@@ -154,7 +158,7 @@ async def get_import_batch(
|
||||
"""
|
||||
SELECT * FROM inventory_import_batches WHERE batch_id=$1 AND tenant_id=$2
|
||||
""",
|
||||
batch_id, user.role,
|
||||
batch_id, _tenant_scope(user),
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(404, "Batch not found")
|
||||
@@ -187,7 +191,7 @@ async def create_property(
|
||||
)
|
||||
RETURNING property_id, created_at
|
||||
""",
|
||||
user.role, body.batch_id, body.source_id, body.project_name, body.developer_name,
|
||||
_tenant_scope(user), body.batch_id, body.source_id, body.project_name, body.developer_name,
|
||||
json.dumps(body.location), body.property_type, json.dumps(body.price_bands),
|
||||
json.dumps(body.unit_mix), body.amenities,
|
||||
body.status, json.dumps(body.validation_state),
|
||||
@@ -207,7 +211,7 @@ async def list_properties(
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
where_clause = "WHERE tenant_id = $1"
|
||||
params: list[Any] = [user.role]
|
||||
params: list[Any] = [_tenant_scope(user)]
|
||||
idx = 2
|
||||
|
||||
if status_filter:
|
||||
@@ -246,7 +250,7 @@ async def get_property(
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT * FROM inventory_properties WHERE property_id=$1 AND tenant_id=$2",
|
||||
property_id, user.role,
|
||||
property_id, _tenant_scope(user),
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(404, "Property not found")
|
||||
@@ -287,8 +291,8 @@ async def update_property(
|
||||
if not updates:
|
||||
raise HTTPException(400, "No fields to update")
|
||||
|
||||
_add("updated_at", datetime.now(UTC))
|
||||
values.extend([property_id, user.role])
|
||||
_add("updated_at", datetime.now(timezone.utc))
|
||||
values.extend([property_id, _tenant_scope(user)])
|
||||
|
||||
pool = _pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
@@ -319,7 +323,7 @@ async def archive_property(
|
||||
SET status='archived', updated_at=NOW()
|
||||
WHERE property_id=$1 AND tenant_id=$2
|
||||
""",
|
||||
property_id, user.role,
|
||||
property_id, _tenant_scope(user),
|
||||
)
|
||||
if result == "UPDATE 0":
|
||||
raise HTTPException(404, "Property not found")
|
||||
@@ -344,7 +348,7 @@ async def add_media(
|
||||
# Verify property belongs to tenant
|
||||
exists = await conn.fetchval(
|
||||
"SELECT 1 FROM inventory_properties WHERE property_id=$1 AND tenant_id=$2",
|
||||
property_id, user.role,
|
||||
property_id, _tenant_scope(user),
|
||||
)
|
||||
if not exists:
|
||||
raise HTTPException(404, "Property not found")
|
||||
@@ -356,7 +360,7 @@ async def add_media(
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8)
|
||||
RETURNING media_asset_id, created_at
|
||||
""",
|
||||
property_id, user.role, body.media_type, body.url, body.thumbnail_url,
|
||||
property_id, _tenant_scope(user), body.media_type, body.url, body.thumbnail_url,
|
||||
body.sort_order, json.dumps(body.metadata), user.user_id,
|
||||
)
|
||||
return {"media_asset_id": str(row["media_asset_id"]), "created_at": str(row["created_at"])}
|
||||
@@ -377,7 +381,7 @@ async def list_media(
|
||||
WHERE property_id=$1 AND tenant_id=$2
|
||||
ORDER BY sort_order ASC, created_at ASC
|
||||
""",
|
||||
property_id, user.role,
|
||||
property_id, _tenant_scope(user),
|
||||
)
|
||||
return {"media": [dict(r) for r in rows]}
|
||||
|
||||
@@ -392,7 +396,7 @@ async def delete_media(
|
||||
async with pool.acquire() as conn:
|
||||
result = await conn.execute(
|
||||
"DELETE FROM inventory_media_assets WHERE media_asset_id=$1 AND tenant_id=$2",
|
||||
media_asset_id, user.role,
|
||||
media_asset_id, _tenant_scope(user),
|
||||
)
|
||||
if result == "DELETE 0":
|
||||
raise HTTPException(404, "Media asset not found")
|
||||
|
||||
@@ -24,7 +24,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
@@ -47,7 +47,11 @@ def _pool(request: Request):
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _tenant_scope(user) -> str:
|
||||
return user.tenant_id
|
||||
|
||||
|
||||
# ── Pydantic models ───────────────────────────────────────────────────────────
|
||||
@@ -63,6 +67,8 @@ VALID_DIRECTIONS = {"inbound", "outbound"}
|
||||
|
||||
VALID_CONSENT = {"unknown", "granted", "denied", "not_required"}
|
||||
|
||||
VALID_CALENDAR_STATUSES = {"tentative", "confirmed", "done", "cancelled"}
|
||||
|
||||
|
||||
class CommunicationEventCreate(BaseModel):
|
||||
lead_id: str
|
||||
@@ -102,6 +108,7 @@ class CalendarEventCreate(BaseModel):
|
||||
start_at: str # ISO8601
|
||||
end_at: str # ISO8601
|
||||
all_day: bool = False
|
||||
status: str = "confirmed"
|
||||
reminder_minutes: list[int] = Field(default_factory=lambda: [15])
|
||||
location: Optional[str] = None
|
||||
metadata: dict = Field(default_factory=dict)
|
||||
@@ -151,12 +158,12 @@ async def list_events(
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT $3 OFFSET $4
|
||||
""",
|
||||
user.role, # tenant_id derived from role scope; production uses dedicated tenant field
|
||||
_tenant_scope(user),
|
||||
lead_id, limit, offset,
|
||||
)
|
||||
total = await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM edge_communication_events WHERE tenant_id = $1 AND lead_id = $2",
|
||||
user.role, lead_id,
|
||||
_tenant_scope(user), lead_id,
|
||||
)
|
||||
return {
|
||||
"total": total,
|
||||
@@ -197,7 +204,7 @@ async def create_event(
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::jsonb)
|
||||
RETURNING event_id, created_at
|
||||
""",
|
||||
user.role, body.lead_id, body.channel, body.direction, body.provider,
|
||||
_tenant_scope(user), body.lead_id, body.channel, body.direction, body.provider,
|
||||
body.capture_mode, body.consent_state, body.duration_seconds,
|
||||
body.summary, body.raw_reference, body.recording_ref,
|
||||
json.dumps(body.provider_metadata),
|
||||
@@ -228,11 +235,11 @@ async def list_memory_facts(
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $3 OFFSET $4
|
||||
""",
|
||||
user.role, lead_id, limit, offset,
|
||||
_tenant_scope(user), lead_id, limit, offset,
|
||||
)
|
||||
total = await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM edge_communication_memory_facts WHERE tenant_id=$1 AND lead_id=$2",
|
||||
user.role, lead_id,
|
||||
_tenant_scope(user), lead_id,
|
||||
)
|
||||
return {"total": total, "limit": limit, "offset": offset, "facts": [dict(r) for r in rows]}
|
||||
|
||||
@@ -265,7 +272,7 @@ async def create_import(
|
||||
) VALUES ($1,$2,$3,'inbound',$4,$5,$6,$7)
|
||||
RETURNING event_id, created_at
|
||||
""",
|
||||
user.role, body.lead_id, body.channel, body.capture_mode,
|
||||
_tenant_scope(user), body.lead_id, body.channel, body.capture_mode,
|
||||
body.consent_state, body.recording_ref, body.summary,
|
||||
)
|
||||
event_id = event_row["event_id"]
|
||||
@@ -279,7 +286,7 @@ async def create_import(
|
||||
) VALUES ($1,$2,'audio',$3)
|
||||
RETURNING transcription_job_id
|
||||
""",
|
||||
user.role, event_id, body.consent_state,
|
||||
_tenant_scope(user), event_id, body.consent_state,
|
||||
)
|
||||
job_id = str(job_row["transcription_job_id"])
|
||||
|
||||
@@ -313,7 +320,7 @@ async def create_note(
|
||||
) VALUES ($1,$2,$3,$4,$5,'operator_note',1.0, TRUE)
|
||||
RETURNING fact_id, created_at
|
||||
""",
|
||||
user.role, body.lead_id, body.fact_type, body.note_text,
|
||||
_tenant_scope(user), body.lead_id, body.fact_type, body.note_text,
|
||||
body.effective_date,
|
||||
)
|
||||
return {"fact_id": str(row["fact_id"]), "created_at": str(row["created_at"])}
|
||||
@@ -338,10 +345,11 @@ async def list_calendar_events(
|
||||
all_day, status, reminder_minutes, created_by, location, metadata, created_at
|
||||
FROM user_calendar_events
|
||||
WHERE tenant_id=$1 AND owner_user_id=$2
|
||||
AND status <> 'cancelled'
|
||||
AND start_at >= $3::timestamptz AND end_at <= $4::timestamptz
|
||||
ORDER BY start_at ASC LIMIT $5
|
||||
""",
|
||||
user.role, user.user_id, from_date, to_date, limit,
|
||||
_tenant_scope(user), user.user_id, from_date, to_date, limit,
|
||||
)
|
||||
else:
|
||||
rows = await conn.fetch(
|
||||
@@ -350,9 +358,10 @@ async def list_calendar_events(
|
||||
all_day, status, reminder_minutes, created_by, location, metadata, created_at
|
||||
FROM user_calendar_events
|
||||
WHERE tenant_id=$1 AND owner_user_id=$2
|
||||
AND status <> 'cancelled'
|
||||
ORDER BY start_at ASC LIMIT $3
|
||||
""",
|
||||
user.role, user.user_id, limit,
|
||||
_tenant_scope(user), user.user_id, limit,
|
||||
)
|
||||
return {"events": [dict(r) for r in rows]}
|
||||
|
||||
@@ -365,21 +374,33 @@ async def create_calendar_event(
|
||||
):
|
||||
pool = _pool(request)
|
||||
import json
|
||||
if body.status not in VALID_CALENDAR_STATUSES:
|
||||
raise HTTPException(status_code=422, detail="Unsupported calendar status.")
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO user_calendar_events (
|
||||
tenant_id, owner_user_id, lead_id, source_event_id, title, description,
|
||||
start_at, end_at, all_day, reminder_minutes, created_by, location, metadata
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7::timestamptz,$8::timestamptz,$9,$10,$11,$12,$13::jsonb)
|
||||
RETURNING calendar_event_id, created_at
|
||||
start_at, end_at, all_day, status, reminder_minutes, created_by, location, metadata
|
||||
) VALUES (
|
||||
$1::text,$2::text,$3::text,$4::uuid,$5::text,$6::text,
|
||||
$7::timestamptz,$8::timestamptz,$9::boolean,$10::text,
|
||||
$11::integer[],$12::text,$13::text,$14::jsonb
|
||||
)
|
||||
RETURNING calendar_event_id, lead_id, title, description, start_at, end_at,
|
||||
all_day, status, reminder_minutes, created_by, location, metadata, created_at
|
||||
""",
|
||||
user.role, user.user_id, body.lead_id, body.source_event_id,
|
||||
_tenant_scope(user), user.user_id, body.lead_id, body.source_event_id,
|
||||
body.title, body.description, body.start_at, body.end_at,
|
||||
body.all_day, body.reminder_minutes, "user",
|
||||
body.all_day, body.status, body.reminder_minutes, "user",
|
||||
body.location, json.dumps(body.metadata),
|
||||
)
|
||||
return {"calendar_event_id": str(row["calendar_event_id"]), "created_at": str(row["created_at"])}
|
||||
event = dict(row)
|
||||
event["calendar_event_id"] = str(event["calendar_event_id"])
|
||||
for key in ("start_at", "end_at", "created_at"):
|
||||
if event.get(key) is not None and hasattr(event[key], "isoformat"):
|
||||
event[key] = event[key].isoformat()
|
||||
return {"status": "ok", "event": event}
|
||||
|
||||
|
||||
@router.patch("/calendar/{calendar_event_id}", summary="Update a calendar event")
|
||||
@@ -405,15 +426,18 @@ async def update_calendar_event(
|
||||
if body.description is not None: _add("description", body.description)
|
||||
if body.start_at is not None: _add("start_at", body.start_at)
|
||||
if body.end_at is not None: _add("end_at", body.end_at)
|
||||
if body.status is not None: _add("status", body.status)
|
||||
if body.status is not None:
|
||||
if body.status not in VALID_CALENDAR_STATUSES:
|
||||
raise HTTPException(status_code=422, detail="Unsupported calendar status.")
|
||||
_add("status", body.status)
|
||||
if body.reminder_minutes is not None: _add("reminder_minutes", body.reminder_minutes)
|
||||
if body.location is not None: _add("location", body.location)
|
||||
|
||||
if not updates:
|
||||
raise HTTPException(400, "No fields to update")
|
||||
|
||||
_add("updated_at", datetime.now(UTC))
|
||||
_add("tenant_id", user.role)
|
||||
_add("updated_at", datetime.now(timezone.utc))
|
||||
_add("tenant_id", _tenant_scope(user))
|
||||
_add("owner_user_id", user.user_id)
|
||||
values.append(calendar_event_id)
|
||||
|
||||
@@ -428,7 +452,7 @@ async def update_calendar_event(
|
||||
)
|
||||
if result == "UPDATE 0":
|
||||
raise HTTPException(404, "Calendar event not found or not owned by you")
|
||||
return {"status": "updated"}
|
||||
return {"status": "updated", "calendar_event_id": calendar_event_id}
|
||||
|
||||
|
||||
@router.delete("/calendar/{calendar_event_id}", summary="Cancel a calendar event")
|
||||
@@ -445,7 +469,7 @@ async def delete_calendar_event(
|
||||
SET status='cancelled', updated_at=NOW()
|
||||
WHERE tenant_id=$1 AND owner_user_id=$2 AND calendar_event_id=$3
|
||||
""",
|
||||
user.role, user.user_id, calendar_event_id,
|
||||
_tenant_scope(user), user.user_id, calendar_event_id,
|
||||
)
|
||||
if result == "UPDATE 0":
|
||||
raise HTTPException(404, "Calendar event not found or not owned by you")
|
||||
@@ -471,7 +495,7 @@ async def get_transcript(
|
||||
WHERE j.event_id = $1 AND e.tenant_id = $2
|
||||
ORDER BY j.created_at DESC LIMIT 1
|
||||
""",
|
||||
event_id, user.role,
|
||||
event_id, _tenant_scope(user),
|
||||
)
|
||||
if not job:
|
||||
raise HTTPException(404, "No transcription job found for this event")
|
||||
@@ -513,7 +537,7 @@ async def get_insights(
|
||||
WHERE tenant_id=$1 AND lead_id=$2 AND status=$3
|
||||
ORDER BY created_at DESC LIMIT $4
|
||||
""",
|
||||
user.role, lead_id, status_filter, limit,
|
||||
_tenant_scope(user), lead_id, status_filter, limit,
|
||||
)
|
||||
else:
|
||||
rows = await conn.fetch(
|
||||
@@ -524,7 +548,7 @@ async def get_insights(
|
||||
WHERE tenant_id=$1 AND lead_id=$2
|
||||
ORDER BY created_at DESC LIMIT $3
|
||||
""",
|
||||
user.role, lead_id, limit,
|
||||
_tenant_scope(user), lead_id, limit,
|
||||
)
|
||||
return {"insights": [dict(r) for r in rows]}
|
||||
|
||||
@@ -544,7 +568,7 @@ async def act_on_insight(
|
||||
SET status=$1, acted_by=$2, acted_at=NOW(), updated_at=NOW()
|
||||
WHERE recommendation_id=$3 AND tenant_id=$4
|
||||
""",
|
||||
body.action, user.user_id, recommendation_id, user.role,
|
||||
body.action, user.user_id, recommendation_id, _tenant_scope(user),
|
||||
)
|
||||
if result == "UPDATE 0":
|
||||
raise HTTPException(404, "Insight not found")
|
||||
@@ -568,7 +592,7 @@ async def get_alerts(
|
||||
async with pool.acquire() as conn:
|
||||
pending_insights = await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM insight_recommendations WHERE tenant_id=$1 AND status='pending'",
|
||||
user.role,
|
||||
_tenant_scope(user),
|
||||
)
|
||||
upcoming_events = await conn.fetchval(
|
||||
"""
|
||||
@@ -577,11 +601,11 @@ async def get_alerts(
|
||||
AND status='confirmed'
|
||||
AND start_at BETWEEN NOW() AND NOW() + INTERVAL '24 hours'
|
||||
""",
|
||||
user.role, user.user_id,
|
||||
_tenant_scope(user), user.user_id,
|
||||
)
|
||||
pending_transcriptions = await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM edge_transcription_jobs WHERE tenant_id=$1 AND status='pending'",
|
||||
user.role,
|
||||
_tenant_scope(user),
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -620,7 +644,7 @@ async def session_heartbeat(
|
||||
ORDER BY last_active_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
user.role, user.user_id, body.surface_type,
|
||||
_tenant_scope(user), user.user_id, body.surface_type,
|
||||
)
|
||||
|
||||
if existing_session_id:
|
||||
@@ -652,7 +676,7 @@ async def session_heartbeat(
|
||||
END
|
||||
)
|
||||
""",
|
||||
user.role, user.user_id, body.surface_type, body.app_version,
|
||||
_tenant_scope(user), user.user_id, body.surface_type, body.app_version,
|
||||
json.dumps(body.metadata), body.screen,
|
||||
)
|
||||
return {"status": "ok", "timestamp": _now()}
|
||||
|
||||
24
backend/api/routes_observability.py
Normal file
24
backend/api/routes_observability.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
|
||||
from backend.auth.dependencies import UserPrincipal, get_current_user
|
||||
from backend.observability import metrics_snapshot
|
||||
|
||||
router = APIRouter(prefix="/observability", tags=["Observability"])
|
||||
|
||||
|
||||
@router.get("/request-metrics")
|
||||
async def request_metrics(
|
||||
request: Request,
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
user: UserPrincipal = Depends(get_current_user),
|
||||
) -> dict:
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"tenant_id": user.tenant_id,
|
||||
"metrics": metrics_snapshot(request.app, limit=limit),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -29,6 +29,10 @@ ROLE_HIERARCHY = {
|
||||
"ADMIN": 3,
|
||||
}
|
||||
|
||||
|
||||
def default_tenant_id() -> str:
|
||||
return os.getenv("VELOCITY_DEFAULT_TENANT_ID", "tenant_velocity").strip() or "tenant_velocity"
|
||||
|
||||
# ── Password hashing ──────────────────────────────────────────────────────────
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
@@ -57,12 +61,14 @@ JWT_ALGORITHM = "HS256"
|
||||
JWT_EXPIRE_HOURS = 8
|
||||
|
||||
|
||||
def create_access_token(user_id: str, role: str) -> str:
|
||||
def create_access_token(user_id: str, role: str, tenant_id: Optional[str] = None) -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRE_HOURS)
|
||||
normalized_role = role.strip().upper()
|
||||
normalized_tenant = (tenant_id or default_tenant_id()).strip() or default_tenant_id()
|
||||
payload = {
|
||||
"sub": user_id,
|
||||
"role": normalized_role,
|
||||
"tenant_id": normalized_tenant,
|
||||
"exp": expire,
|
||||
"iat": datetime.now(timezone.utc),
|
||||
}
|
||||
@@ -75,6 +81,7 @@ def create_access_token(user_id: str, role: str) -> str:
|
||||
class UserPrincipal:
|
||||
user_id: str
|
||||
role: str
|
||||
tenant_id: str = default_tenant_id()
|
||||
|
||||
@property
|
||||
def role_level(self) -> int:
|
||||
@@ -112,7 +119,11 @@ def get_current_user(
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
) from exc
|
||||
|
||||
return UserPrincipal(user_id=payload["sub"], role=str(payload["role"]).strip().upper())
|
||||
return UserPrincipal(
|
||||
user_id=payload["sub"],
|
||||
role=str(payload["role"]).strip().upper(),
|
||||
tenant_id=str(payload.get("tenant_id") or default_tenant_id()).strip() or default_tenant_id(),
|
||||
)
|
||||
|
||||
|
||||
# ── Dependency factory: role gate ─────────────────────────────────────────────
|
||||
|
||||
105
backend/auth/routes.py
Normal file
105
backend/auth/routes.py
Normal file
@@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.auth.dependencies import UserPrincipal, get_current_user
|
||||
from backend.auth.service import (
|
||||
list_tenant_users,
|
||||
login_with_directory,
|
||||
read_authenticated_user_profile,
|
||||
)
|
||||
from backend.auth.user_directory import ensure_user_directory_schema
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
ASSET_DIR = os.getenv("VELOCITY_ASSET_DIR", "/opt/dlami/nvme/assets")
|
||||
|
||||
|
||||
def _sanitize_filename(value: str) -> str:
|
||||
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", value).strip("._")
|
||||
return cleaned or "upload"
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
|
||||
|
||||
@router.post("/api/auth/login", tags=["Auth"])
|
||||
async def login(body: LoginRequest, request: Request):
|
||||
"""
|
||||
Authenticate a user and return a JWT.
|
||||
Credentials are verified against the users_and_roles table.
|
||||
"""
|
||||
return await login_with_directory(
|
||||
app=request.app,
|
||||
email=body.email,
|
||||
password=body.password,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/auth/me", tags=["Auth"])
|
||||
async def me(request: Request, user: UserPrincipal = Depends(get_current_user)):
|
||||
return await read_authenticated_user_profile(app=request.app, user=user)
|
||||
|
||||
|
||||
@router.get("/api/auth/users", tags=["Auth"])
|
||||
async def list_auth_users(request: Request, user: UserPrincipal = Depends(get_current_user)):
|
||||
return await list_tenant_users(app=request.app, user=user)
|
||||
|
||||
|
||||
@router.post("/api/auth/profile/avatar", tags=["Auth"])
|
||||
async def upload_profile_avatar(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
user: UserPrincipal = Depends(get_current_user),
|
||||
):
|
||||
await ensure_user_directory_schema(request.app)
|
||||
pool = getattr(request.app.state, "db_pool", None)
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="Database unavailable.")
|
||||
|
||||
allowed = {"image/png", "image/jpeg", "image/jpg", "image/webp"}
|
||||
if file.content_type not in allowed:
|
||||
raise HTTPException(status_code=400, detail="Unsupported avatar format.")
|
||||
|
||||
extension = Path(file.filename or "avatar.png").suffix.lower() or ".png"
|
||||
if extension not in {".png", ".jpg", ".jpeg", ".webp"}:
|
||||
extension = ".png"
|
||||
|
||||
avatar_dir = Path(ASSET_DIR) / "profile_avatars"
|
||||
avatar_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
filename = (
|
||||
f"{user.user_id}_{_sanitize_filename(Path(file.filename or 'avatar').stem)}_"
|
||||
f"{int(datetime.now(timezone.utc).timestamp())}{extension}"
|
||||
)
|
||||
destination = avatar_dir / filename
|
||||
contents = await file.read()
|
||||
destination.write_bytes(contents)
|
||||
|
||||
avatar_url = f"/assets/profile_avatars/{filename}"
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
result = await conn.execute(
|
||||
"""
|
||||
UPDATE users_and_roles
|
||||
SET avatar_url = $2
|
||||
WHERE id = $1::uuid
|
||||
AND tenant_id = $3
|
||||
""",
|
||||
user.user_id,
|
||||
avatar_url,
|
||||
user.tenant_id,
|
||||
)
|
||||
|
||||
if result == "UPDATE 0":
|
||||
raise HTTPException(status_code=404, detail="Authenticated user profile was not found.")
|
||||
|
||||
return {"avatar_url": avatar_url}
|
||||
123
backend/auth/service.py
Normal file
123
backend/auth/service.py
Normal file
@@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from backend.auth.dependencies import (
|
||||
UserPrincipal,
|
||||
create_access_token,
|
||||
default_tenant_id,
|
||||
verify_password,
|
||||
)
|
||||
from backend.auth.user_directory import ensure_user_directory_schema
|
||||
|
||||
|
||||
async def _get_pool(app: Any):
|
||||
pool = getattr(app.state, "db_pool", None)
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="Database unavailable.")
|
||||
return pool
|
||||
|
||||
|
||||
async def login_with_directory(*, app: Any, email: str, password: str) -> dict[str, Any]:
|
||||
await ensure_user_directory_schema(app)
|
||||
pool = await _get_pool(app)
|
||||
tenant_fallback = default_tenant_id()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT
|
||||
id::text,
|
||||
role,
|
||||
password_hash,
|
||||
COALESCE(NULLIF(tenant_id, ''), $2) AS tenant_id
|
||||
FROM users_and_roles
|
||||
WHERE email = $1 AND is_active = TRUE
|
||||
""",
|
||||
email.strip(),
|
||||
tenant_fallback,
|
||||
)
|
||||
|
||||
if not row or not verify_password(password, row["password_hash"]):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid email or password.",
|
||||
)
|
||||
|
||||
token = create_access_token(
|
||||
user_id=row["id"],
|
||||
role=row["role"],
|
||||
tenant_id=row["tenant_id"],
|
||||
)
|
||||
return {"access_token": token, "token_type": "bearer", "expires_in": 28800}
|
||||
|
||||
|
||||
async def read_authenticated_user_profile(*, app: Any, user: UserPrincipal) -> dict[str, Any]:
|
||||
await ensure_user_directory_schema(app)
|
||||
pool = await _get_pool(app)
|
||||
tenant_scope = user.tenant_id or default_tenant_id()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT
|
||||
full_name,
|
||||
email,
|
||||
avatar_url,
|
||||
COALESCE(NULLIF(tenant_id, ''), $2) AS tenant_id
|
||||
FROM users_and_roles
|
||||
WHERE id = $1::uuid
|
||||
AND COALESCE(NULLIF(tenant_id, ''), $2) = $2
|
||||
""",
|
||||
user.user_id,
|
||||
tenant_scope,
|
||||
)
|
||||
|
||||
return {
|
||||
"user_id": user.user_id,
|
||||
"role": user.role,
|
||||
"tenant_id": row["tenant_id"] if row else tenant_scope,
|
||||
"full_name": row["full_name"] if row else None,
|
||||
"email": row["email"] if row else None,
|
||||
"avatar_url": row["avatar_url"] if row else None,
|
||||
}
|
||||
|
||||
|
||||
async def list_tenant_users(*, app: Any, user: UserPrincipal) -> list[dict[str, Any]]:
|
||||
await ensure_user_directory_schema(app)
|
||||
pool = await _get_pool(app)
|
||||
tenant_scope = user.tenant_id or default_tenant_id()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT
|
||||
id::text AS user_id,
|
||||
role,
|
||||
COALESCE(NULLIF(tenant_id, ''), $1) AS tenant_id,
|
||||
full_name,
|
||||
email,
|
||||
avatar_url
|
||||
FROM users_and_roles
|
||||
WHERE is_active = TRUE
|
||||
AND COALESCE(NULLIF(tenant_id, ''), $1) = $2
|
||||
ORDER BY
|
||||
COALESCE(NULLIF(full_name, ''), email, id::text) ASC
|
||||
""",
|
||||
default_tenant_id(),
|
||||
tenant_scope,
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"user_id": row["user_id"],
|
||||
"role": row["role"],
|
||||
"tenant_id": row["tenant_id"],
|
||||
"full_name": row["full_name"],
|
||||
"email": row["email"],
|
||||
"avatar_url": row["avatar_url"],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
45
backend/auth/user_directory.py
Normal file
45
backend/auth/user_directory.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from backend.auth.dependencies import default_tenant_id
|
||||
|
||||
_AUTH_USER_DIRECTORY_SCHEMA_CACHE_KEY = "_auth_user_directory_schema_ready"
|
||||
|
||||
|
||||
def _sql_text_literal(value: str) -> str:
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
|
||||
|
||||
async def ensure_user_directory_schema(app: Any) -> None:
|
||||
if getattr(app.state, _AUTH_USER_DIRECTORY_SCHEMA_CACHE_KEY, False):
|
||||
return
|
||||
|
||||
pool = getattr(app.state, "db_pool", None)
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="Database unavailable.")
|
||||
|
||||
tenant_fallback = default_tenant_id()
|
||||
tenant_default_literal = _sql_text_literal(tenant_fallback)
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute("ALTER TABLE users_and_roles ADD COLUMN IF NOT EXISTS tenant_id TEXT")
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE users_and_roles
|
||||
SET tenant_id = $1
|
||||
WHERE tenant_id IS NULL OR tenant_id = ''
|
||||
""",
|
||||
tenant_fallback,
|
||||
)
|
||||
await conn.execute(
|
||||
f"ALTER TABLE users_and_roles ALTER COLUMN tenant_id SET DEFAULT {tenant_default_literal}"
|
||||
)
|
||||
await conn.execute("ALTER TABLE users_and_roles ALTER COLUMN tenant_id SET NOT NULL")
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_users_tenant_active ON users_and_roles (tenant_id, is_active)"
|
||||
)
|
||||
|
||||
setattr(app.state, _AUTH_USER_DIRECTORY_SCHEMA_CACHE_KEY, True)
|
||||
88
backend/crm/canonical_schema.py
Normal file
88
backend/crm/canonical_schema.py
Normal file
@@ -0,0 +1,88 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from backend.auth.dependencies import default_tenant_id
|
||||
|
||||
_CANONICAL_CRM_SCHEMA_CACHE_KEY = "_canonical_crm_schema_ready"
|
||||
|
||||
|
||||
def _sql_text_literal(value: str) -> str:
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
|
||||
|
||||
async def ensure_canonical_crm_schema(app: Any) -> None:
|
||||
if getattr(app.state, _CANONICAL_CRM_SCHEMA_CACHE_KEY, False):
|
||||
return
|
||||
|
||||
pool = getattr(app.state, "db_pool", None)
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="Database unavailable.")
|
||||
|
||||
tenant_fallback = default_tenant_id()
|
||||
tenant_default_literal = _sql_text_literal(tenant_fallback)
|
||||
tenant_tables = (
|
||||
"crm_people",
|
||||
"crm_accounts",
|
||||
"crm_leads",
|
||||
"crm_opportunities",
|
||||
"crm_property_interests",
|
||||
"intel_interactions",
|
||||
"intel_reminders",
|
||||
"intel_qd_scores",
|
||||
"intel_qd_timeseries",
|
||||
"workflow_actions",
|
||||
"workflow_approvals",
|
||||
"workflow_import_batches",
|
||||
)
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
for table_name in tenant_tables:
|
||||
await conn.execute(f"ALTER TABLE {table_name} ADD COLUMN IF NOT EXISTS tenant_id TEXT")
|
||||
await conn.execute(
|
||||
f"""
|
||||
UPDATE {table_name}
|
||||
SET tenant_id = $1
|
||||
WHERE tenant_id IS NULL OR tenant_id = ''
|
||||
""",
|
||||
tenant_fallback,
|
||||
)
|
||||
await conn.execute(
|
||||
f"ALTER TABLE {table_name} ALTER COLUMN tenant_id SET DEFAULT {tenant_default_literal}"
|
||||
)
|
||||
await conn.execute(f"ALTER TABLE {table_name} ALTER COLUMN tenant_id SET NOT NULL")
|
||||
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_crm_people_tenant_created ON crm_people (tenant_id, created_at DESC)"
|
||||
)
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_crm_leads_tenant_status ON crm_leads (tenant_id, status, updated_at DESC)"
|
||||
)
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_crm_opportunities_tenant_stage ON crm_opportunities (tenant_id, stage, updated_at DESC)"
|
||||
)
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_crm_property_interests_tenant_person ON crm_property_interests (tenant_id, person_id, created_at DESC)"
|
||||
)
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_intel_interactions_tenant_person ON intel_interactions (tenant_id, person_id, happened_at DESC)"
|
||||
)
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_intel_reminders_tenant_status ON intel_reminders (tenant_id, status, due_at)"
|
||||
)
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_intel_qd_scores_tenant_person ON intel_qd_scores (tenant_id, person_id, score_type)"
|
||||
)
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_intel_qd_timeseries_tenant_person ON intel_qd_timeseries (tenant_id, person_id, timestamp DESC)"
|
||||
)
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_workflow_actions_tenant_status ON workflow_actions (tenant_id, status, created_at DESC)"
|
||||
)
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_workflow_import_batches_tenant_lifecycle ON workflow_import_batches (tenant_id, lifecycle, created_at DESC)"
|
||||
)
|
||||
|
||||
setattr(app.state, _CANONICAL_CRM_SCHEMA_CACHE_KEY, True)
|
||||
@@ -59,6 +59,7 @@ CREATE TABLE IF NOT EXISTS users_and_roles (
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role role_enum NOT NULL DEFAULT 'JUNIOR_BROKER',
|
||||
tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity',
|
||||
full_name TEXT,
|
||||
avatar_url TEXT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
@@ -68,6 +69,7 @@ CREATE TABLE IF NOT EXISTS users_and_roles (
|
||||
|
||||
-- Index for login lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users_and_roles (email);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_tenant_active ON users_and_roles (tenant_id, is_active);
|
||||
|
||||
-- ────────────────────────────────────────────────────────────────────────────
|
||||
-- TABLE: leads_intelligence (CRM core with QD scoring)
|
||||
|
||||
@@ -645,6 +645,34 @@ CREATE TABLE IF NOT EXISTS workflow_agent_runs (
|
||||
CREATE INDEX IF NOT EXISTS idx_wf_agent_runs_agent ON workflow_agent_runs (agent_name, started_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_wf_agent_runs_status ON workflow_agent_runs (status);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- TENANT HARDENING FOR SHARED CRM SURFACES
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
ALTER TABLE crm_people ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
|
||||
ALTER TABLE crm_accounts ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
|
||||
ALTER TABLE crm_leads ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
|
||||
ALTER TABLE crm_opportunities ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
|
||||
ALTER TABLE crm_property_interests ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
|
||||
ALTER TABLE intel_interactions ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
|
||||
ALTER TABLE intel_reminders ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
|
||||
ALTER TABLE intel_qd_scores ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
|
||||
ALTER TABLE intel_qd_timeseries ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
|
||||
ALTER TABLE workflow_actions ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
|
||||
ALTER TABLE workflow_approvals ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
|
||||
ALTER TABLE workflow_import_batches ADD COLUMN IF NOT EXISTS tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_crm_people_tenant_created ON crm_people (tenant_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_crm_leads_tenant_status ON crm_leads (tenant_id, status, updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_crm_opportunities_tenant_stage ON crm_opportunities (tenant_id, stage, updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_crm_property_interests_tenant_person ON crm_property_interests (tenant_id, person_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_intel_interactions_tenant_person ON intel_interactions (tenant_id, person_id, happened_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_intel_reminders_tenant_status ON intel_reminders (tenant_id, status, due_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_intel_qd_scores_tenant_person ON intel_qd_scores (tenant_id, person_id, score_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_intel_qd_timeseries_tenant_person ON intel_qd_timeseries (tenant_id, person_id, timestamp DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_wf_actions_tenant_status ON workflow_actions (tenant_id, status, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_wf_import_batches_tenant_lifecycle ON workflow_import_batches (tenant_id, lifecycle, created_at DESC);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- TRIGGERS: auto-update updated_at
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
172
backend/main.py
172
backend/main.py
@@ -11,13 +11,12 @@ import os
|
||||
import json
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import UTC, datetime
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Set
|
||||
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, HTTPException, status, UploadFile, File
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from dotenv import load_dotenv
|
||||
@@ -62,12 +61,14 @@ from backend.api.routes_mobile_edge import router as mobile_edge_router
|
||||
from backend.api.routes_inventory import router as inventory_router
|
||||
from backend.api.routes_admin_surface import router as admin_surface_router
|
||||
from backend.api.routes_oracle_templates import router as oracle_templates_router
|
||||
from backend.api.routes_observability import router as observability_router
|
||||
from backend.api.routes_crm_imports import router as crm_imports_router
|
||||
from backend.api.routes_runtime_llm import router as runtime_llm_router
|
||||
from backend.auth.dependencies import (
|
||||
create_access_token, verify_password, get_current_user, UserPrincipal
|
||||
)
|
||||
from backend.auth.routes import router as auth_router
|
||||
from backend.auth.user_directory import ensure_user_directory_schema
|
||||
from backend.db.pool import create_pool, close_pool
|
||||
from backend.migrations.runner import apply_migrations
|
||||
from backend.observability import RequestObservabilityMiddleware
|
||||
from backend.oracle.router_v1 import router as oracle_v1_router
|
||||
from backend.routers.cctv import router as cctv_router
|
||||
from backend.routers.scenes import router as scenes_router
|
||||
@@ -86,6 +87,11 @@ async def lifespan(app: FastAPI):
|
||||
try:
|
||||
app.state.db_pool = await create_pool()
|
||||
logger.info("asyncpg pool created")
|
||||
async with app.state.db_pool.acquire() as conn:
|
||||
applied = await apply_migrations(conn)
|
||||
if applied:
|
||||
logger.info("Applied backend migrations: %s", ", ".join(applied))
|
||||
await ensure_user_directory_schema(app)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to create DB pool: %s", exc)
|
||||
app.state.db_pool = None
|
||||
@@ -118,6 +124,7 @@ app.add_middleware(
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.add_middleware(RequestObservabilityMiddleware)
|
||||
|
||||
# ── Static asset serving (Vault files) ───────────────────────────────────────
|
||||
|
||||
@@ -125,11 +132,6 @@ ASSET_DIR = os.getenv("VELOCITY_ASSET_DIR", "/opt/dlami/nvme/assets")
|
||||
if os.path.isdir(ASSET_DIR):
|
||||
app.mount("/assets", StaticFiles(directory=ASSET_DIR), name="assets")
|
||||
|
||||
|
||||
def _sanitize_filename(value: str) -> str:
|
||||
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", value).strip("._")
|
||||
return cleaned or "upload"
|
||||
|
||||
# ── Routers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
app.include_router(catalyst_router, prefix="/api/catalyst", tags=["Catalyst"])
|
||||
@@ -146,6 +148,7 @@ app.include_router(vault_router, prefix="/api/vault", tags=["Vault"])
|
||||
app.include_router(mobile_edge_router, prefix="/api/mobile-edge", tags=["Mobile Edge"])
|
||||
app.include_router(inventory_router, prefix="/api/inventory", tags=["Inventory"])
|
||||
app.include_router(admin_surface_router, prefix="/api/admin-surface", tags=["Admin Surface"])
|
||||
app.include_router(observability_router, prefix="/api", tags=["Observability"])
|
||||
app.include_router(crm_imports_router, prefix="/api", tags=["CRM Canonical"])
|
||||
app.include_router(runtime_llm_router, prefix="/api/runtime/llm", tags=["Runtime LLM"])
|
||||
|
||||
@@ -153,144 +156,6 @@ app.include_router(runtime_llm_router, prefix="/api/runtime/llm", tags=["Runtime
|
||||
from backend.routers.vault import router as public_vault_router
|
||||
app.include_router(public_vault_router, prefix="/vault", tags=["Vault Public"])
|
||||
|
||||
# ── Auth endpoint ─────────────────────────────────────────────────────────────
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
|
||||
@app.post("/api/auth/login", tags=["Auth"])
|
||||
async def login(body: LoginRequest):
|
||||
"""
|
||||
Authenticate a user and return a JWT.
|
||||
Credentials are verified against the users_and_roles table.
|
||||
"""
|
||||
from backend.db.pool import get_pool
|
||||
from fastapi import Request
|
||||
|
||||
pool = app.state.db_pool
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="Database unavailable.")
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT id::text, role, password_hash FROM users_and_roles WHERE email = $1 AND is_active = TRUE",
|
||||
body.email,
|
||||
)
|
||||
|
||||
if not row or not verify_password(body.password, row["password_hash"]):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid email or password.",
|
||||
)
|
||||
|
||||
token = create_access_token(user_id=row["id"], role=row["role"])
|
||||
return {"access_token": token, "token_type": "bearer", "expires_in": 28800}
|
||||
|
||||
|
||||
@app.get("/api/auth/me", tags=["Auth"])
|
||||
async def me(user: UserPrincipal = Depends(get_current_user)):
|
||||
pool = app.state.db_pool
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="Database unavailable.")
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT full_name, email, avatar_url
|
||||
FROM users_and_roles
|
||||
WHERE id = $1::uuid
|
||||
""",
|
||||
user.user_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"user_id": user.user_id,
|
||||
"role": user.role,
|
||||
"full_name": row["full_name"] if row else None,
|
||||
"email": row["email"] if row else None,
|
||||
"avatar_url": row["avatar_url"] if row else None,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/auth/users", tags=["Auth"])
|
||||
async def list_auth_users(_: UserPrincipal = Depends(get_current_user)):
|
||||
pool = app.state.db_pool
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="Database unavailable.")
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT
|
||||
id::text AS user_id,
|
||||
role,
|
||||
full_name,
|
||||
email,
|
||||
avatar_url
|
||||
FROM users_and_roles
|
||||
WHERE is_active = TRUE
|
||||
ORDER BY
|
||||
COALESCE(NULLIF(full_name, ''), email, id::text) ASC
|
||||
"""
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"user_id": row["user_id"],
|
||||
"role": row["role"],
|
||||
"full_name": row["full_name"],
|
||||
"email": row["email"],
|
||||
"avatar_url": row["avatar_url"],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@app.post("/api/auth/profile/avatar", tags=["Auth"])
|
||||
async def upload_profile_avatar(
|
||||
file: UploadFile = File(...),
|
||||
user: UserPrincipal = Depends(get_current_user),
|
||||
):
|
||||
pool = app.state.db_pool
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="Database unavailable.")
|
||||
|
||||
allowed = {"image/png", "image/jpeg", "image/jpg", "image/webp"}
|
||||
if file.content_type not in allowed:
|
||||
raise HTTPException(status_code=400, detail="Unsupported avatar format.")
|
||||
|
||||
extension = Path(file.filename or "avatar.png").suffix.lower() or ".png"
|
||||
if extension not in {".png", ".jpg", ".jpeg", ".webp"}:
|
||||
extension = ".png"
|
||||
|
||||
avatar_dir = Path(ASSET_DIR) / "profile_avatars"
|
||||
avatar_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
filename = f"{user.user_id}_{_sanitize_filename(Path(file.filename or 'avatar').stem)}_{int(datetime.now(UTC).timestamp())}{extension}"
|
||||
destination = avatar_dir / filename
|
||||
contents = await file.read()
|
||||
destination.write_bytes(contents)
|
||||
|
||||
avatar_url = f"/assets/profile_avatars/{filename}"
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE users_and_roles
|
||||
SET avatar_url = $2
|
||||
WHERE id = $1::uuid
|
||||
""",
|
||||
user.user_id,
|
||||
avatar_url,
|
||||
)
|
||||
|
||||
return {"avatar_url": avatar_url}
|
||||
|
||||
|
||||
# ── Catalyst WebSocket (preserved from v1) ────────────────────────────────────
|
||||
|
||||
class _CatalystManager:
|
||||
@@ -359,7 +224,7 @@ async def crm_ws(ws: WebSocket) -> None:
|
||||
{
|
||||
"type": "crm_presence",
|
||||
"connected_clients": len(_crm_mgr.active),
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
)
|
||||
try:
|
||||
@@ -376,7 +241,7 @@ async def broadcast_live_event(event_type, message, campaign_name=None, value=No
|
||||
"message": message,
|
||||
"campaignName": campaign_name,
|
||||
"value": value,
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
await _catalyst_mgr.broadcast(payload)
|
||||
|
||||
@@ -387,7 +252,7 @@ app.state.broadcast_live_event = broadcast_live_event
|
||||
async def broadcast_crm_event(payload: dict) -> None:
|
||||
enriched = {
|
||||
**payload,
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
await _crm_mgr.broadcast(enriched)
|
||||
|
||||
@@ -406,6 +271,5 @@ async def health() -> dict:
|
||||
"service": "velocity-backend",
|
||||
"version": "2.0.0",
|
||||
"db_pool": "connected" if db_ok else "unavailable",
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
2
backend/migrations/__init__.py
Normal file
2
backend/migrations/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Velocity backend migration utilities."""
|
||||
|
||||
102
backend/migrations/runner.py
Normal file
102
backend/migrations/runner.py
Normal file
@@ -0,0 +1,102 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
MIGRATIONS_DIR = Path(__file__).resolve().parent / "versions"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Migration:
|
||||
version: str
|
||||
name: str
|
||||
path: Path
|
||||
checksum: str
|
||||
sql: str
|
||||
|
||||
|
||||
def _checksum(sql: str) -> str:
|
||||
return hashlib.sha256(sql.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def discover_migrations(directory: Path = MIGRATIONS_DIR) -> list[Migration]:
|
||||
if not directory.exists():
|
||||
return []
|
||||
|
||||
migrations: list[Migration] = []
|
||||
for path in sorted(directory.glob("*.sql")):
|
||||
version, _, name = path.stem.partition("_")
|
||||
if not version or not name:
|
||||
raise ValueError(f"Invalid migration filename: {path.name}")
|
||||
sql = path.read_text(encoding="utf-8")
|
||||
migrations.append(
|
||||
Migration(
|
||||
version=version,
|
||||
name=name,
|
||||
path=path,
|
||||
checksum=_checksum(sql),
|
||||
sql=sql,
|
||||
)
|
||||
)
|
||||
|
||||
seen: set[str] = set()
|
||||
for migration in migrations:
|
||||
if migration.version in seen:
|
||||
raise ValueError(f"Duplicate migration version: {migration.version}")
|
||||
seen.add(migration.version)
|
||||
return migrations
|
||||
|
||||
|
||||
async def ensure_migration_table(conn) -> None:
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
checksum TEXT NOT NULL,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def applied_versions(conn) -> dict[str, str]:
|
||||
await ensure_migration_table(conn)
|
||||
rows = await conn.fetch("SELECT version, checksum FROM schema_migrations")
|
||||
return {row["version"]: row["checksum"] for row in rows}
|
||||
|
||||
|
||||
async def apply_migrations(conn, migrations: Iterable[Migration] | None = None) -> list[str]:
|
||||
pending = list(migrations if migrations is not None else discover_migrations())
|
||||
applied = await applied_versions(conn)
|
||||
applied_now: list[str] = []
|
||||
|
||||
for migration in pending:
|
||||
existing_checksum = applied.get(migration.version)
|
||||
if existing_checksum == migration.checksum:
|
||||
continue
|
||||
if existing_checksum and existing_checksum != migration.checksum:
|
||||
raise RuntimeError(
|
||||
f"Migration checksum mismatch for {migration.version}; "
|
||||
"create a new migration instead of editing an applied one."
|
||||
)
|
||||
|
||||
transaction = conn.transaction()
|
||||
async with transaction:
|
||||
await conn.execute(migration.sql)
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO schema_migrations (version, name, checksum)
|
||||
VALUES ($1, $2, $3)
|
||||
""",
|
||||
migration.version,
|
||||
migration.name,
|
||||
migration.checksum,
|
||||
)
|
||||
applied_now.append(migration.version)
|
||||
|
||||
return applied_now
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Velocity production observability foundation.
|
||||
-- Creates a lightweight table for durable request/error telemetry when enabled.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_request_events (
|
||||
event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
request_id TEXT NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
status_code INTEGER NOT NULL,
|
||||
duration_ms DOUBLE PRECISION NOT NULL,
|
||||
tenant_id TEXT,
|
||||
user_id UUID,
|
||||
error_type TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_app_request_events_created_at
|
||||
ON app_request_events (created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_app_request_events_path_status
|
||||
ON app_request_events (path, status_code, created_at DESC);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_calendar_events (
|
||||
calendar_event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id TEXT NOT NULL,
|
||||
owner_user_id TEXT NOT NULL,
|
||||
lead_id TEXT,
|
||||
source_event_id UUID,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
start_at TIMESTAMPTZ NOT NULL,
|
||||
end_at TIMESTAMPTZ NOT NULL,
|
||||
all_day BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
status TEXT NOT NULL DEFAULT 'confirmed'
|
||||
CHECK (status IN ('tentative', 'confirmed', 'done', 'cancelled')),
|
||||
reminder_minutes INTEGER[] NOT NULL DEFAULT '{15}'::INTEGER[],
|
||||
created_by TEXT NOT NULL DEFAULT 'user'
|
||||
CHECK (created_by IN ('user', 'nemoclaw_suggested', 'operator_import')),
|
||||
is_nemoclaw_confirmed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
location TEXT,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_calendar_events_owner
|
||||
ON user_calendar_events (tenant_id, owner_user_id, start_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_calendar_events_lead
|
||||
ON user_calendar_events (tenant_id, lead_id, start_at);
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE user_calendar_events
|
||||
DROP CONSTRAINT IF EXISTS user_calendar_events_status_check;
|
||||
|
||||
ALTER TABLE user_calendar_events
|
||||
ADD CONSTRAINT user_calendar_events_status_check
|
||||
CHECK (status IN ('tentative', 'confirmed', 'done', 'cancelled'));
|
||||
103
backend/observability.py
Normal file
103
backend/observability.py
Normal file
@@ -0,0 +1,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from collections import deque
|
||||
from dataclasses import asdict, dataclass
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
logger = logging.getLogger("velocity.observability")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RequestMetric:
|
||||
request_id: str
|
||||
method: str
|
||||
path: str
|
||||
status_code: int
|
||||
duration_ms: float
|
||||
|
||||
|
||||
class RequestObservabilityMiddleware(BaseHTTPMiddleware):
|
||||
def __init__(self, app, *, max_metrics: int = 500) -> None:
|
||||
super().__init__(app)
|
||||
self.max_metrics = max_metrics
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
request_id = request.headers.get("x-request-id") or str(uuid.uuid4())
|
||||
request.state.request_id = request_id
|
||||
started = time.perf_counter()
|
||||
status_code = 500
|
||||
|
||||
try:
|
||||
response = await call_next(request)
|
||||
status_code = response.status_code
|
||||
return self._finalize(request, response, request_id, started, status_code)
|
||||
except Exception:
|
||||
duration_ms = (time.perf_counter() - started) * 1000
|
||||
self._record_metric(request, request_id, status_code, duration_ms)
|
||||
logger.exception(
|
||||
"request_failed",
|
||||
extra={
|
||||
"request_id": request_id,
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"duration_ms": round(duration_ms, 2),
|
||||
},
|
||||
)
|
||||
raise
|
||||
|
||||
def _finalize(
|
||||
self,
|
||||
request: Request,
|
||||
response: Response,
|
||||
request_id: str,
|
||||
started: float,
|
||||
status_code: int,
|
||||
) -> Response:
|
||||
duration_ms = (time.perf_counter() - started) * 1000
|
||||
response.headers["X-Request-ID"] = request_id
|
||||
response.headers["X-Response-Time-Ms"] = f"{duration_ms:.2f}"
|
||||
self._record_metric(request, request_id, status_code, duration_ms)
|
||||
logger.info(
|
||||
"request_completed",
|
||||
extra={
|
||||
"request_id": request_id,
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"status_code": status_code,
|
||||
"duration_ms": round(duration_ms, 2),
|
||||
},
|
||||
)
|
||||
return response
|
||||
|
||||
def _record_metric(
|
||||
self,
|
||||
request: Request,
|
||||
request_id: str,
|
||||
status_code: int,
|
||||
duration_ms: float,
|
||||
) -> None:
|
||||
metrics = getattr(request.app.state, "request_metrics", None)
|
||||
if metrics is None:
|
||||
metrics = deque(maxlen=self.max_metrics)
|
||||
request.app.state.request_metrics = metrics
|
||||
metrics.append(
|
||||
RequestMetric(
|
||||
request_id=request_id,
|
||||
method=request.method,
|
||||
path=request.url.path,
|
||||
status_code=status_code,
|
||||
duration_ms=round(duration_ms, 2),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def metrics_snapshot(app, *, limit: int = 50) -> list[dict]:
|
||||
metrics = getattr(app.state, "request_metrics", deque())
|
||||
return [asdict(metric) for metric in list(metrics)[-limit:]][::-1]
|
||||
|
||||
@@ -223,7 +223,7 @@ CREATE TABLE IF NOT EXISTS user_calendar_events (
|
||||
end_at TIMESTAMPTZ NOT NULL,
|
||||
all_day BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
status TEXT NOT NULL DEFAULT 'confirmed'
|
||||
CHECK (status IN ('tentative','confirmed','cancelled')),
|
||||
CHECK (status IN ('tentative','confirmed','done','cancelled')),
|
||||
reminder_minutes INTEGER[] NOT NULL DEFAULT '{15}'::INTEGER[],
|
||||
created_by TEXT NOT NULL
|
||||
CHECK (created_by IN ('user','nemoclaw_suggested','operator_import')),
|
||||
|
||||
850
backend/scripts/seed_ipad_investor_demo.py
Normal file
850
backend/scripts/seed_ipad_investor_demo.py
Normal file
@@ -0,0 +1,850 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Seed realistic, idempotent iPad investor-demo data into the current operator tenant.
|
||||
|
||||
The script writes only canonical Velocity domains used by the iPad app:
|
||||
crm_*, intel_*, workflow_*, inventory_*, mobile-edge events, and calendar events.
|
||||
Rows are tagged with metadata_json/source identifiers so they are auditable and safe
|
||||
to re-run without duplication.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
try:
|
||||
import asyncpg
|
||||
except ModuleNotFoundError: # pragma: no cover - exercised by operator environment
|
||||
asyncpg = None # type: ignore[assignment]
|
||||
|
||||
|
||||
SEED_SOURCE = "velocity_ipad_investor_demo_2026_04"
|
||||
DEFAULT_OPERATOR_EMAIL = "sayan@desineuron.in"
|
||||
NAMESPACE = uuid.uuid5(uuid.NAMESPACE_URL, "https://desineuron.in/project-velocity/ipad-investor-demo")
|
||||
|
||||
|
||||
def _load_env() -> None:
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
for candidate in (repo_root / "backend" / ".env", repo_root / ".env"):
|
||||
if candidate.exists():
|
||||
load_dotenv(candidate, override=False)
|
||||
load_dotenv(override=False)
|
||||
|
||||
|
||||
def _stable_uuid(tenant_id: str, key: str) -> str:
|
||||
return str(uuid.uuid5(NAMESPACE, f"{tenant_id}:{key}"))
|
||||
|
||||
|
||||
def _json(value: Any) -> str:
|
||||
return json.dumps(value, separators=(",", ":"), ensure_ascii=True)
|
||||
|
||||
|
||||
def _db_kwargs() -> dict[str, Any]:
|
||||
if os.getenv("DATABASE_URL"):
|
||||
return {"dsn": os.environ["DATABASE_URL"]}
|
||||
required = ["VELOCITY_DB_NAME", "VELOCITY_DB_USER", "VELOCITY_DB_PASSWORD"]
|
||||
missing = [key for key in required if not os.getenv(key)]
|
||||
if missing:
|
||||
raise RuntimeError(
|
||||
"Missing database configuration: "
|
||||
+ ", ".join(missing)
|
||||
+ ". Set DATABASE_URL or VELOCITY_DB_* variables before running the seed."
|
||||
)
|
||||
return {
|
||||
"host": os.getenv("VELOCITY_DB_HOST", "localhost"),
|
||||
"port": int(os.getenv("VELOCITY_DB_PORT", "5432")),
|
||||
"database": os.environ["VELOCITY_DB_NAME"],
|
||||
"user": os.environ["VELOCITY_DB_USER"],
|
||||
"password": os.environ["VELOCITY_DB_PASSWORD"],
|
||||
}
|
||||
|
||||
|
||||
def _expected_counts() -> dict[str, int]:
|
||||
return {
|
||||
"people": len(DEMO_CLIENTS),
|
||||
"leads": len(DEMO_CLIENTS),
|
||||
"projects": len(PROJECTS),
|
||||
"properties": len(PROJECTS),
|
||||
"interests": len(DEMO_CLIENTS),
|
||||
"opportunities": len(DEMO_CLIENTS),
|
||||
"scores": len(DEMO_CLIENTS) * 3,
|
||||
"interactions": len(DEMO_CLIENTS) * 3,
|
||||
"edge_events": len(DEMO_CLIENTS),
|
||||
"reminders": len(DEMO_CLIENTS),
|
||||
"calendar_events": len(DEMO_CLIENTS),
|
||||
"import_batches": 1,
|
||||
"import_proposals": 3,
|
||||
}
|
||||
|
||||
|
||||
async def _resolve_operator(conn: asyncpg.Connection, email: str, tenant_override: str | None) -> tuple[str, str | None]:
|
||||
if tenant_override:
|
||||
user_id = await conn.fetchval(
|
||||
"SELECT id::text FROM users_and_roles WHERE email = $1 AND tenant_id = $2 LIMIT 1",
|
||||
email,
|
||||
tenant_override,
|
||||
)
|
||||
return tenant_override, user_id
|
||||
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT id::text AS user_id, tenant_id
|
||||
FROM users_and_roles
|
||||
WHERE email = $1
|
||||
AND is_active = TRUE
|
||||
ORDER BY last_login DESC NULLS LAST, created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
email,
|
||||
)
|
||||
if row:
|
||||
return row["tenant_id"], row["user_id"]
|
||||
tenant_id = os.getenv("VELOCITY_DEFAULT_TENANT_ID", "tenant_velocity")
|
||||
return tenant_id, None
|
||||
|
||||
|
||||
DEMO_CLIENTS = [
|
||||
{
|
||||
"key": "meera-sen",
|
||||
"name": "Meera Sen",
|
||||
"email": "meera.sen.investor@demo.desineuron.in",
|
||||
"phone": "+91-98745-11820",
|
||||
"buyer_type": "hni_end_user",
|
||||
"city": "Kolkata",
|
||||
"nationality": "Indian",
|
||||
"persona": ["family_upgrader", "waterfront_preference"],
|
||||
"budget": "12-18 Cr",
|
||||
"urgency": "high",
|
||||
"status": "qualified",
|
||||
"project": "Atri Aqua Sky Residences",
|
||||
"configuration": "4BHK Sky Villa",
|
||||
"unit": "Tower A / High floor",
|
||||
"budget_min": 120000000,
|
||||
"budget_max": 180000000,
|
||||
"stage": "proposal",
|
||||
"value": 154000000,
|
||||
"probability": 72,
|
||||
"next_action": "Share revised payment schedule and club-deck walkthrough slots.",
|
||||
"scores": (0.91, 0.86, 0.88),
|
||||
},
|
||||
{
|
||||
"key": "arjun-malhotra",
|
||||
"name": "Arjun Malhotra",
|
||||
"email": "arjun.malhotra.nri@demo.desineuron.in",
|
||||
"phone": "+91-98102-44591",
|
||||
"buyer_type": "nri_investor",
|
||||
"city": "Dubai",
|
||||
"nationality": "Indian",
|
||||
"persona": ["nri_portfolio_buyer", "rental_yield_focused"],
|
||||
"budget": "8-12 Cr",
|
||||
"urgency": "medium",
|
||||
"status": "site_visit_scheduled",
|
||||
"project": "Alipore Azure Residences",
|
||||
"configuration": "3BHK Signature",
|
||||
"unit": "Tower B / 21st floor",
|
||||
"budget_min": 80000000,
|
||||
"budget_max": 120000000,
|
||||
"stage": "site_visit",
|
||||
"value": 98000000,
|
||||
"probability": 61,
|
||||
"next_action": "Coordinate Saturday family video walkthrough and NRI remittance checklist.",
|
||||
"scores": (0.82, 0.79, 0.63),
|
||||
},
|
||||
{
|
||||
"key": "devika-roy",
|
||||
"name": "Devika Roy",
|
||||
"email": "devika.roy.familyoffice@demo.desineuron.in",
|
||||
"phone": "+91-99033-28741",
|
||||
"buyer_type": "family_office",
|
||||
"city": "Mumbai",
|
||||
"nationality": "Indian",
|
||||
"persona": ["portfolio_allocator", "low_visibility_buyer"],
|
||||
"budget": "20-30 Cr",
|
||||
"urgency": "critical",
|
||||
"status": "negotiation",
|
||||
"project": "Victoria Gardens Private Residences",
|
||||
"configuration": "Penthouse Duplex",
|
||||
"unit": "Private elevator stack",
|
||||
"budget_min": 200000000,
|
||||
"budget_max": 300000000,
|
||||
"stage": "negotiation",
|
||||
"value": 265000000,
|
||||
"probability": 78,
|
||||
"next_action": "Prepare founder-level commercial note before family office review.",
|
||||
"scores": (0.95, 0.91, 0.96),
|
||||
},
|
||||
{
|
||||
"key": "rohan-kapoor",
|
||||
"name": "Rohan Kapoor",
|
||||
"email": "rohan.kapoor.startup@demo.desineuron.in",
|
||||
"phone": "+91-98311-73654",
|
||||
"buyer_type": "founder_buyer",
|
||||
"city": "Bengaluru",
|
||||
"nationality": "Indian",
|
||||
"persona": ["founder_liquidity_event", "design_led_buyer"],
|
||||
"budget": "6-10 Cr",
|
||||
"urgency": "high",
|
||||
"status": "contacted",
|
||||
"project": "Salt Lake Atelier Homes",
|
||||
"configuration": "3.5BHK Garden Home",
|
||||
"unit": "Podium garden facing",
|
||||
"budget_min": 60000000,
|
||||
"budget_max": 100000000,
|
||||
"stage": "qualified",
|
||||
"value": 83500000,
|
||||
"probability": 54,
|
||||
"next_action": "Send design moodboard and schedule Dream Weaver room concept.",
|
||||
"scores": (0.76, 0.83, 0.81),
|
||||
},
|
||||
{
|
||||
"key": "saira-hussain",
|
||||
"name": "Saira Hussain",
|
||||
"email": "saira.hussain.doctor@demo.desineuron.in",
|
||||
"phone": "+91-97482-66019",
|
||||
"buyer_type": "end_user",
|
||||
"city": "Kolkata",
|
||||
"nationality": "Indian",
|
||||
"persona": ["quiet_luxury", "school_proximity"],
|
||||
"budget": "4-6 Cr",
|
||||
"urgency": "medium",
|
||||
"status": "site_visited",
|
||||
"project": "Ballygunge Meridian",
|
||||
"configuration": "3BHK",
|
||||
"unit": "South-east corner",
|
||||
"budget_min": 40000000,
|
||||
"budget_max": 60000000,
|
||||
"stage": "proposal",
|
||||
"value": 52000000,
|
||||
"probability": 66,
|
||||
"next_action": "Share school-route comparison and revised parking availability.",
|
||||
"scores": (0.74, 0.68, 0.62),
|
||||
},
|
||||
{
|
||||
"key": "vikram-jalan",
|
||||
"name": "Vikram Jalan",
|
||||
"email": "vikram.jalan.broker@demo.desineuron.in",
|
||||
"phone": "+91-98300-91274",
|
||||
"buyer_type": "broker_referral",
|
||||
"city": "Kolkata",
|
||||
"nationality": "Indian",
|
||||
"persona": ["broker_network", "bulk_referral_potential"],
|
||||
"budget": "15-25 Cr",
|
||||
"urgency": "high",
|
||||
"status": "booking_initiated",
|
||||
"project": "Atri Aqua Sky Residences",
|
||||
"configuration": "4BHK River Deck",
|
||||
"unit": "Two adjacent units",
|
||||
"budget_min": 150000000,
|
||||
"budget_max": 250000000,
|
||||
"stage": "booking",
|
||||
"value": 212000000,
|
||||
"probability": 84,
|
||||
"next_action": "Confirm booking amount routing and broker mandate documentation.",
|
||||
"scores": (0.88, 0.77, 0.89),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
PROJECTS = {
|
||||
"Atri Aqua Sky Residences": {
|
||||
"developer": "Atri Group",
|
||||
"micro_market": "Batanagar Riverside",
|
||||
"address": "Maheshtala Riverside Corridor, Kolkata",
|
||||
"property_type": "apartment",
|
||||
"location": {"city": "Kolkata", "district": "Maheshtala", "lat": 22.4981, "lng": 88.2291},
|
||||
"price_bands": [
|
||||
{"unitType": "3BHK", "minINR": 72000000, "maxINR": 98000000},
|
||||
{"unitType": "4BHK Sky Villa", "minINR": 130000000, "maxINR": 190000000},
|
||||
],
|
||||
"unit_mix": [{"bedrooms": 3, "count": 42, "sizeSqft": 2450}, {"bedrooms": 4, "count": 18, "sizeSqft": 3800}],
|
||||
},
|
||||
"Alipore Azure Residences": {
|
||||
"developer": "Meridian Urban Estates",
|
||||
"micro_market": "Alipore",
|
||||
"address": "Judges Court Road, Alipore, Kolkata",
|
||||
"property_type": "apartment",
|
||||
"location": {"city": "Kolkata", "district": "Alipore", "lat": 22.5288, "lng": 88.3309},
|
||||
"price_bands": [{"unitType": "3BHK Signature", "minINR": 85000000, "maxINR": 125000000}],
|
||||
"unit_mix": [{"bedrooms": 3, "count": 36, "sizeSqft": 2850}, {"bedrooms": 4, "count": 16, "sizeSqft": 4100}],
|
||||
},
|
||||
"Victoria Gardens Private Residences": {
|
||||
"developer": "Heritage Habitat",
|
||||
"micro_market": "Maidan",
|
||||
"address": "Queen's Way Precinct, Kolkata",
|
||||
"property_type": "penthouse",
|
||||
"location": {"city": "Kolkata", "district": "Maidan", "lat": 22.5448, "lng": 88.3426},
|
||||
"price_bands": [{"unitType": "Penthouse Duplex", "minINR": 220000000, "maxINR": 320000000}],
|
||||
"unit_mix": [{"bedrooms": 5, "count": 8, "sizeSqft": 6200}],
|
||||
},
|
||||
"Salt Lake Atelier Homes": {
|
||||
"developer": "Studio Habitat",
|
||||
"micro_market": "Salt Lake Sector V",
|
||||
"address": "EM Bypass Connector, Salt Lake, Kolkata",
|
||||
"property_type": "apartment",
|
||||
"location": {"city": "Kolkata", "district": "Salt Lake", "lat": 22.5797, "lng": 88.4353},
|
||||
"price_bands": [{"unitType": "3.5BHK Garden Home", "minINR": 68000000, "maxINR": 98000000}],
|
||||
"unit_mix": [{"bedrooms": 3, "count": 28, "sizeSqft": 2350}],
|
||||
},
|
||||
"Ballygunge Meridian": {
|
||||
"developer": "Eastern Crest Realty",
|
||||
"micro_market": "Ballygunge",
|
||||
"address": "Ballygunge Circular Road, Kolkata",
|
||||
"property_type": "apartment",
|
||||
"location": {"city": "Kolkata", "district": "Ballygunge", "lat": 22.5276, "lng": 88.3651},
|
||||
"price_bands": [{"unitType": "3BHK", "minINR": 42000000, "maxINR": 62000000}],
|
||||
"unit_mix": [{"bedrooms": 3, "count": 54, "sizeSqft": 1780}],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def seed(conn: asyncpg.Connection, tenant_id: str, operator_user_id: str | None, dry_run: bool = False) -> dict[str, int]:
|
||||
now = datetime.now(timezone.utc)
|
||||
owner_user_ref = operator_user_id or f"{SEED_SOURCE}:operator"
|
||||
counts = {
|
||||
"people": 0,
|
||||
"leads": 0,
|
||||
"projects": 0,
|
||||
"properties": 0,
|
||||
"interests": 0,
|
||||
"opportunities": 0,
|
||||
"scores": 0,
|
||||
"interactions": 0,
|
||||
"edge_events": 0,
|
||||
"reminders": 0,
|
||||
"calendar_events": 0,
|
||||
"import_batches": 0,
|
||||
"import_proposals": 0,
|
||||
}
|
||||
if dry_run:
|
||||
return counts
|
||||
|
||||
project_ids: dict[str, str] = {}
|
||||
for name, project in PROJECTS.items():
|
||||
project_id = _stable_uuid(tenant_id, f"project:{name}")
|
||||
project_ids[name] = project_id
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO inventory_projects (
|
||||
project_id, project_name, developer_name, city, micro_market, address,
|
||||
total_units, project_status, location_json, amenities_json, metadata_json
|
||||
) VALUES (
|
||||
$1::uuid, $2, $3, 'Kolkata', $4, $5, $6, 'active',
|
||||
$7::jsonb, $8::jsonb, $9::jsonb
|
||||
)
|
||||
ON CONFLICT (project_name) DO UPDATE SET
|
||||
developer_name = EXCLUDED.developer_name,
|
||||
micro_market = EXCLUDED.micro_market,
|
||||
address = EXCLUDED.address,
|
||||
total_units = EXCLUDED.total_units,
|
||||
project_status = EXCLUDED.project_status,
|
||||
location_json = EXCLUDED.location_json,
|
||||
amenities_json = EXCLUDED.amenities_json,
|
||||
metadata_json = inventory_projects.metadata_json || EXCLUDED.metadata_json,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
project_id,
|
||||
name,
|
||||
project["developer"],
|
||||
project["micro_market"],
|
||||
project["address"],
|
||||
sum(item["count"] for item in project["unit_mix"]),
|
||||
_json(project["location"]),
|
||||
_json(["concierge", "private club", "fitness studio", "visitor lounge", "ev charging"]),
|
||||
_json({"seed_source": SEED_SOURCE, "ipad_demo": True}),
|
||||
)
|
||||
counts["projects"] += 1
|
||||
|
||||
property_id = _stable_uuid(tenant_id, f"inventory-property:{name}")
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO inventory_properties (
|
||||
property_id, tenant_id, source_id, project_name, developer_name,
|
||||
location, property_type, price_bands, unit_mix, amenities,
|
||||
status, validation_state, ingested_at, created_at, updated_at
|
||||
) VALUES (
|
||||
$1::uuid, $2, $3, $4, $5, $6::jsonb, $7, $8::jsonb, $9::jsonb,
|
||||
$10, 'active', $11::jsonb, NOW(), NOW(), NOW()
|
||||
)
|
||||
ON CONFLICT (property_id) DO UPDATE SET
|
||||
project_name = EXCLUDED.project_name,
|
||||
developer_name = EXCLUDED.developer_name,
|
||||
location = EXCLUDED.location,
|
||||
property_type = EXCLUDED.property_type,
|
||||
price_bands = EXCLUDED.price_bands,
|
||||
unit_mix = EXCLUDED.unit_mix,
|
||||
amenities = EXCLUDED.amenities,
|
||||
status = 'active',
|
||||
validation_state = EXCLUDED.validation_state,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
property_id,
|
||||
tenant_id,
|
||||
f"{SEED_SOURCE}:{name}",
|
||||
name,
|
||||
project["developer"],
|
||||
_json(project["location"]),
|
||||
project["property_type"],
|
||||
_json(project["price_bands"]),
|
||||
_json(project["unit_mix"]),
|
||||
["concierge", "clubhouse", "security", "ev charging", "landscaped deck"],
|
||||
_json({"seed_source": SEED_SOURCE, "validated_for_ipad_demo": True}),
|
||||
)
|
||||
counts["properties"] += 1
|
||||
|
||||
for index, client in enumerate(DEMO_CLIENTS):
|
||||
person_id = _stable_uuid(tenant_id, f"person:{client['key']}")
|
||||
lead_id = _stable_uuid(tenant_id, f"lead:{client['key']}")
|
||||
opportunity_id = _stable_uuid(tenant_id, f"opportunity:{client['key']}")
|
||||
project_id = project_ids[client["project"]]
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO crm_people (
|
||||
person_id, tenant_id, full_name, primary_email, primary_phone, city,
|
||||
nationality, buyer_type, persona_labels, source_confidence,
|
||||
metadata_json, created_at, updated_at
|
||||
) VALUES (
|
||||
$1::uuid, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, 0.98,
|
||||
$10::jsonb, NOW() - ($11::int * INTERVAL '2 days'), NOW()
|
||||
)
|
||||
ON CONFLICT (person_id) DO UPDATE SET
|
||||
full_name = EXCLUDED.full_name,
|
||||
primary_email = EXCLUDED.primary_email,
|
||||
primary_phone = EXCLUDED.primary_phone,
|
||||
city = EXCLUDED.city,
|
||||
nationality = EXCLUDED.nationality,
|
||||
buyer_type = EXCLUDED.buyer_type,
|
||||
persona_labels = EXCLUDED.persona_labels,
|
||||
source_confidence = EXCLUDED.source_confidence,
|
||||
metadata_json = crm_people.metadata_json || EXCLUDED.metadata_json,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
person_id,
|
||||
tenant_id,
|
||||
client["name"],
|
||||
client["email"],
|
||||
client["phone"],
|
||||
client["city"],
|
||||
client["nationality"],
|
||||
client["buyer_type"],
|
||||
_json(client["persona"]),
|
||||
_json({"seed_source": SEED_SOURCE, "investor_demo": True, "source_note": "iPad production readiness seed"}),
|
||||
index,
|
||||
)
|
||||
counts["people"] += 1
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO crm_leads (
|
||||
lead_id, tenant_id, person_id, source_system, status, budget_band,
|
||||
urgency, financing_posture, timeline_to_decision, objections,
|
||||
motivations, assigned_user_id, metadata_json, created_at, updated_at
|
||||
) VALUES (
|
||||
$1::uuid, $2, $3::uuid, 'ipad_investor_demo',
|
||||
$4::crm_lead_status, $5, $6, $7, $8, $9::jsonb, $10::jsonb,
|
||||
$11::uuid, $12::jsonb, NOW() - ($13::int * INTERVAL '2 days'), NOW()
|
||||
)
|
||||
ON CONFLICT (lead_id) DO UPDATE SET
|
||||
status = EXCLUDED.status,
|
||||
budget_band = EXCLUDED.budget_band,
|
||||
urgency = EXCLUDED.urgency,
|
||||
financing_posture = EXCLUDED.financing_posture,
|
||||
timeline_to_decision = EXCLUDED.timeline_to_decision,
|
||||
objections = EXCLUDED.objections,
|
||||
motivations = EXCLUDED.motivations,
|
||||
assigned_user_id = EXCLUDED.assigned_user_id,
|
||||
metadata_json = crm_leads.metadata_json || EXCLUDED.metadata_json,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
lead_id,
|
||||
tenant_id,
|
||||
person_id,
|
||||
client["status"],
|
||||
client["budget"],
|
||||
client["urgency"],
|
||||
"cash_and_structured_payment" if client["budget_min"] >= 100000000 else "bank_loan_preapproved",
|
||||
"30_days" if client["urgency"] in {"high", "critical"} else "60_to_90_days",
|
||||
_json(["needs_family_alignment"] if client["urgency"] != "critical" else ["price_protection", "privacy"]),
|
||||
_json(["upgrade_primary_home", "wealth_preservation", "status_address"]),
|
||||
operator_user_id,
|
||||
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
|
||||
index,
|
||||
)
|
||||
counts["leads"] += 1
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO crm_property_interests (
|
||||
interest_id, tenant_id, person_id, lead_id, project_id, project_name,
|
||||
unit_preference, configuration, budget_min, budget_max, priority, notes, created_at
|
||||
) VALUES (
|
||||
$1::uuid, $2, $3::uuid, $4::uuid, $5::uuid, $6, $7, $8, $9, $10, 1, $11, NOW()
|
||||
)
|
||||
ON CONFLICT (interest_id) DO UPDATE SET
|
||||
project_name = EXCLUDED.project_name,
|
||||
unit_preference = EXCLUDED.unit_preference,
|
||||
configuration = EXCLUDED.configuration,
|
||||
budget_min = EXCLUDED.budget_min,
|
||||
budget_max = EXCLUDED.budget_max,
|
||||
notes = EXCLUDED.notes
|
||||
""",
|
||||
_stable_uuid(tenant_id, f"interest:{client['key']}"),
|
||||
tenant_id,
|
||||
person_id,
|
||||
lead_id,
|
||||
project_id,
|
||||
client["project"],
|
||||
client["unit"],
|
||||
client["configuration"],
|
||||
client["budget_min"],
|
||||
client["budget_max"],
|
||||
f"Seeded for iPad investor demo by {SEED_SOURCE}.",
|
||||
)
|
||||
counts["interests"] += 1
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO crm_opportunities (
|
||||
opportunity_id, tenant_id, lead_id, project_id, stage, value,
|
||||
probability, expected_close_date, next_action, notes, metadata_json,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
$1::uuid, $2, $3::uuid, $4::uuid, $5::crm_opportunity_stage, $6,
|
||||
$7, $8::date, $9, $10, $11::jsonb, NOW() - ($12::int * INTERVAL '1 day'), NOW()
|
||||
)
|
||||
ON CONFLICT (opportunity_id) DO UPDATE SET
|
||||
project_id = EXCLUDED.project_id,
|
||||
stage = EXCLUDED.stage,
|
||||
value = EXCLUDED.value,
|
||||
probability = EXCLUDED.probability,
|
||||
expected_close_date = EXCLUDED.expected_close_date,
|
||||
next_action = EXCLUDED.next_action,
|
||||
notes = EXCLUDED.notes,
|
||||
metadata_json = crm_opportunities.metadata_json || EXCLUDED.metadata_json,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
opportunity_id,
|
||||
tenant_id,
|
||||
lead_id,
|
||||
project_id,
|
||||
client["stage"],
|
||||
client["value"],
|
||||
client["probability"],
|
||||
(now + timedelta(days=21 + index * 4)).date().isoformat(),
|
||||
client["next_action"],
|
||||
"Investor-demo opportunity with realistic project, value, and next-step context.",
|
||||
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
|
||||
index,
|
||||
)
|
||||
counts["opportunities"] += 1
|
||||
|
||||
for score_type, value in zip(("intent_score", "engagement_score", "urgency_score"), client["scores"]):
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO intel_qd_scores (
|
||||
qd_id, tenant_id, person_id, score_type, current_value,
|
||||
computed_at, evidence_refs_json, reasoning, metadata_json
|
||||
) VALUES (
|
||||
$1::uuid, $2, $3::uuid, $4, $5, NOW(), $6::jsonb, $7, $8::jsonb
|
||||
)
|
||||
ON CONFLICT (person_id, score_type) DO UPDATE SET
|
||||
tenant_id = EXCLUDED.tenant_id,
|
||||
current_value = EXCLUDED.current_value,
|
||||
computed_at = NOW(),
|
||||
evidence_refs_json = EXCLUDED.evidence_refs_json,
|
||||
reasoning = EXCLUDED.reasoning,
|
||||
metadata_json = intel_qd_scores.metadata_json || EXCLUDED.metadata_json
|
||||
""",
|
||||
_stable_uuid(tenant_id, f"qd:{client['key']}:{score_type}"),
|
||||
tenant_id,
|
||||
person_id,
|
||||
score_type,
|
||||
value,
|
||||
_json([f"seed:{client['key']}:interaction"]),
|
||||
f"{client['name']} shows {score_type.replace('_', ' ')} from recent budget, project, and follow-up signals.",
|
||||
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
|
||||
)
|
||||
counts["scores"] += 1
|
||||
|
||||
interaction_templates = [
|
||||
("whatsapp", "message", now - timedelta(hours=6 + index), "Confirmed budget band and asked for a project comparison deck."),
|
||||
("phone", "call", now - timedelta(days=1, hours=index), "Discussed decision timeline, family alignment, and next visit window."),
|
||||
("site_visit", "visit", now - timedelta(days=3 + index), f"Reviewed {client['project']} and shortlisted {client['configuration']}."),
|
||||
]
|
||||
for event_index, (channel, interaction_type, happened_at, summary) in enumerate(interaction_templates):
|
||||
interaction_id = _stable_uuid(tenant_id, f"interaction:{client['key']}:{event_index}")
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO intel_interactions (
|
||||
interaction_id, tenant_id, person_id, lead_id, channel, interaction_type,
|
||||
happened_at, summary, source_ref, metadata_json, created_at
|
||||
) VALUES (
|
||||
$1::uuid, $2, $3::uuid, $4::uuid, $5::intel_channel, $6,
|
||||
$7, $8, $9, $10::jsonb, NOW()
|
||||
)
|
||||
ON CONFLICT (interaction_id) DO UPDATE SET
|
||||
happened_at = EXCLUDED.happened_at,
|
||||
summary = EXCLUDED.summary,
|
||||
metadata_json = intel_interactions.metadata_json || EXCLUDED.metadata_json
|
||||
""",
|
||||
interaction_id,
|
||||
tenant_id,
|
||||
person_id,
|
||||
lead_id,
|
||||
channel,
|
||||
interaction_type,
|
||||
happened_at,
|
||||
summary,
|
||||
f"{SEED_SOURCE}:{client['key']}:{event_index}",
|
||||
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
|
||||
)
|
||||
counts["interactions"] += 1
|
||||
|
||||
edge_event_id = _stable_uuid(tenant_id, f"edge-event:{client['key']}")
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO edge_communication_events (
|
||||
event_id, tenant_id, lead_id, channel, direction, provider,
|
||||
capture_mode, consent_state, timestamp, duration_seconds,
|
||||
summary, raw_reference, provider_metadata, created_at, updated_at
|
||||
) VALUES (
|
||||
$1::uuid, $2, $3, 'whatsapp_message', 'inbound', 'operator_seed',
|
||||
'operator_note', 'granted', $4, NULL, $5, $6, $7::jsonb, NOW(), NOW()
|
||||
)
|
||||
ON CONFLICT (event_id) DO UPDATE SET
|
||||
timestamp = EXCLUDED.timestamp,
|
||||
summary = EXCLUDED.summary,
|
||||
provider_metadata = EXCLUDED.provider_metadata,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
edge_event_id,
|
||||
tenant_id,
|
||||
lead_id,
|
||||
now - timedelta(minutes=25 + index * 12),
|
||||
f"{client['name']} asked the operator to proceed with the next step: {client['next_action']}",
|
||||
f"{SEED_SOURCE}:{client['key']}",
|
||||
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
|
||||
)
|
||||
counts["edge_events"] += 1
|
||||
|
||||
reminder_id = _stable_uuid(tenant_id, f"reminder:{client['key']}")
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO intel_reminders (
|
||||
reminder_id, tenant_id, person_id, lead_id, opportunity_id, reminder_type,
|
||||
title, notes, due_at, status, assigned_to, created_by_type, priority,
|
||||
metadata_json, created_at
|
||||
) VALUES (
|
||||
$1::uuid, $2, $3::uuid, $4::uuid, $5::uuid, 'follow_up',
|
||||
$6, $7, $8, 'pending', $9::uuid, 'human', $10, $11::jsonb, NOW()
|
||||
)
|
||||
ON CONFLICT (reminder_id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
notes = EXCLUDED.notes,
|
||||
due_at = EXCLUDED.due_at,
|
||||
status = 'pending',
|
||||
assigned_to = EXCLUDED.assigned_to,
|
||||
priority = EXCLUDED.priority,
|
||||
metadata_json = intel_reminders.metadata_json || EXCLUDED.metadata_json
|
||||
""",
|
||||
reminder_id,
|
||||
tenant_id,
|
||||
person_id,
|
||||
lead_id,
|
||||
opportunity_id,
|
||||
f"Follow up with {client['name']} on {client['project']}",
|
||||
client["next_action"],
|
||||
now + timedelta(hours=3 + index * 4),
|
||||
operator_user_id,
|
||||
"urgent" if client["urgency"] == "critical" else ("high" if client["urgency"] == "high" else "normal"),
|
||||
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
|
||||
)
|
||||
counts["reminders"] += 1
|
||||
|
||||
calendar_id = _stable_uuid(tenant_id, f"calendar:{client['key']}")
|
||||
start_at = now + timedelta(days=1 + (index % 3), hours=2 + index)
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO user_calendar_events (
|
||||
calendar_event_id, tenant_id, owner_user_id, lead_id, source_event_id,
|
||||
title, description, start_at, end_at, all_day, status,
|
||||
reminder_minutes, created_by, is_nemoclaw_confirmed, location,
|
||||
metadata, created_at, updated_at
|
||||
) VALUES (
|
||||
$1::uuid, $2, $3, $4, $5::uuid, $6, $7, $8, $9, FALSE, 'confirmed',
|
||||
ARRAY[15, 60], 'user', TRUE, $10, $11::jsonb, NOW(), NOW()
|
||||
)
|
||||
ON CONFLICT (calendar_event_id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
description = EXCLUDED.description,
|
||||
start_at = EXCLUDED.start_at,
|
||||
end_at = EXCLUDED.end_at,
|
||||
status = 'confirmed',
|
||||
reminder_minutes = EXCLUDED.reminder_minutes,
|
||||
location = EXCLUDED.location,
|
||||
metadata = EXCLUDED.metadata,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
calendar_id,
|
||||
tenant_id,
|
||||
owner_user_ref,
|
||||
lead_id,
|
||||
edge_event_id,
|
||||
f"{client['name']} - {client['configuration']} review",
|
||||
client["next_action"],
|
||||
start_at,
|
||||
start_at + timedelta(minutes=45),
|
||||
client["project"],
|
||||
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
|
||||
)
|
||||
counts["calendar_events"] += 1
|
||||
|
||||
batch_id = _stable_uuid(tenant_id, "workflow-import-batch:investor-demo")
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO workflow_import_batches (
|
||||
batch_id, tenant_id, source_system, uploaded_filename, mime_type, storage_ref,
|
||||
row_count, mapped_count, unresolved_count, canonical_count, uploaded_by,
|
||||
lifecycle, mapping_manifest, errors_json, metadata_json, created_at, updated_at
|
||||
) VALUES (
|
||||
$1::uuid, $2, 'sales_ops_csv', 'investor_demo_priority_clients.csv', 'text/csv',
|
||||
$3, 6, 5, 1, 0, $4::uuid, 'proposed',
|
||||
$5::jsonb, '[]'::jsonb, $6::jsonb, NOW() - INTERVAL '2 hours', NOW()
|
||||
)
|
||||
ON CONFLICT (batch_id) DO UPDATE SET
|
||||
row_count = EXCLUDED.row_count,
|
||||
mapped_count = EXCLUDED.mapped_count,
|
||||
unresolved_count = EXCLUDED.unresolved_count,
|
||||
lifecycle = EXCLUDED.lifecycle,
|
||||
mapping_manifest = EXCLUDED.mapping_manifest,
|
||||
metadata_json = workflow_import_batches.metadata_json || EXCLUDED.metadata_json,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
batch_id,
|
||||
tenant_id,
|
||||
f"s3://velocity-demo/{SEED_SOURCE}/investor_demo_priority_clients.csv",
|
||||
operator_user_id,
|
||||
_json({"mapped": {"Name": "full_name", "Phone": "primary_phone", "Budget": "budget_band"}}),
|
||||
_json({"seed_source": SEED_SOURCE, "investor_demo": True}),
|
||||
)
|
||||
counts["import_batches"] += 1
|
||||
|
||||
for row_number, client in enumerate(DEMO_CLIENTS[:3], start=1):
|
||||
action_id = _stable_uuid(tenant_id, f"workflow-import-proposal:{client['key']}")
|
||||
payload = {
|
||||
"batch_id": batch_id,
|
||||
"row_number": row_number,
|
||||
"canonical_payload": {
|
||||
"full_name": client["name"],
|
||||
"primary_phone": client["phone"],
|
||||
"buyer_type": client["buyer_type"],
|
||||
"budget_band": client["budget"],
|
||||
"project_name": client["project"],
|
||||
},
|
||||
"raw_row": {
|
||||
"Name": client["name"],
|
||||
"Phone": client["phone"],
|
||||
"Budget": client["budget"],
|
||||
"Project": client["project"],
|
||||
},
|
||||
"unresolved_fields": [] if row_number < 3 else ["preferred_visit_time"],
|
||||
"missing_required": [],
|
||||
"confidence": 0.92 - (row_number * 0.04),
|
||||
"review_required": True,
|
||||
}
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO workflow_actions (
|
||||
action_id, tenant_id, action_type, target_domain, target_entity_ref,
|
||||
proposal_payload, reasoning_summary, evidence_refs, confidence,
|
||||
status, approval_required, created_by_agent, created_at, updated_at
|
||||
) VALUES (
|
||||
$1::uuid, $2, 'import_proposal', 'crm', $3,
|
||||
$4::jsonb, $5, $6::jsonb, $7, 'pending', TRUE,
|
||||
'velocity_ipad_seed', NOW() - INTERVAL '90 minutes', NOW()
|
||||
)
|
||||
ON CONFLICT (action_id) DO UPDATE SET
|
||||
proposal_payload = EXCLUDED.proposal_payload,
|
||||
reasoning_summary = EXCLUDED.reasoning_summary,
|
||||
evidence_refs = EXCLUDED.evidence_refs,
|
||||
confidence = EXCLUDED.confidence,
|
||||
status = 'pending',
|
||||
approval_required = TRUE,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
action_id,
|
||||
tenant_id,
|
||||
client["email"],
|
||||
_json(payload),
|
||||
f"Mapped {client['name']} from realistic investor-demo CRM import.",
|
||||
_json([f"batch:{batch_id}", f"seed_source:{SEED_SOURCE}"]),
|
||||
payload["confidence"],
|
||||
)
|
||||
counts["import_proposals"] += 1
|
||||
|
||||
return counts
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--operator-email", default=os.getenv("VELOCITY_DEMO_OPERATOR_EMAIL", DEFAULT_OPERATOR_EMAIL))
|
||||
parser.add_argument("--tenant-id", default=os.getenv("VELOCITY_DEMO_TENANT_ID"))
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
_load_env()
|
||||
if asyncpg is None:
|
||||
raise RuntimeError(
|
||||
"asyncpg is not installed. Install backend requirements first: "
|
||||
"python3 -m pip install -r backend/requirements.txt"
|
||||
)
|
||||
if args.dry_run:
|
||||
try:
|
||||
db_kwargs = _db_kwargs()
|
||||
except RuntimeError as exc:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"status": "dry_run_without_db",
|
||||
"seed_source": SEED_SOURCE,
|
||||
"operator_email": args.operator_email,
|
||||
"database_note": str(exc),
|
||||
"expected_counts": _expected_counts(),
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return
|
||||
else:
|
||||
db_kwargs = _db_kwargs()
|
||||
conn = await asyncpg.connect(**db_kwargs)
|
||||
try:
|
||||
tenant_id, operator_user_id = await _resolve_operator(conn, args.operator_email, args.tenant_id)
|
||||
counts = _expected_counts() if args.dry_run else await seed(conn, tenant_id, operator_user_id)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"status": "dry_run" if args.dry_run else "seeded",
|
||||
"seed_source": SEED_SOURCE,
|
||||
"tenant_id": tenant_id,
|
||||
"operator_email": args.operator_email,
|
||||
"operator_user_id": operator_user_id,
|
||||
"counts": counts,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -11,12 +11,35 @@ As specified in Doc 07 (Client360Snapshot contract) and Doc 08 (Adapter Spec).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("velocity.client_graph.aggregation")
|
||||
|
||||
|
||||
def _json_string_list(value: Any) -> list[str]:
|
||||
"""Normalize canonical array fields that may arrive as jsonb, text[], or JSON text."""
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list | tuple):
|
||||
return [str(item) for item in value if item is not None]
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
return []
|
||||
try:
|
||||
parsed = json.loads(normalized)
|
||||
except json.JSONDecodeError:
|
||||
return [normalized]
|
||||
if isinstance(parsed, list):
|
||||
return [str(item) for item in parsed if item is not None]
|
||||
if parsed is None:
|
||||
return []
|
||||
return [str(parsed)]
|
||||
return [str(value)]
|
||||
|
||||
|
||||
def _serialize_person(row: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"person_id": str(row["person_id"]),
|
||||
@@ -24,7 +47,7 @@ def _serialize_person(row: Any) -> dict[str, Any]:
|
||||
"primary_email": row["primary_email"],
|
||||
"primary_phone": row["primary_phone"],
|
||||
"buyer_type": row["buyer_type"],
|
||||
"persona_labels": row["persona_labels"] or [],
|
||||
"persona_labels": _json_string_list(row["persona_labels"]),
|
||||
"source_confidence": float(row["source_confidence"] or 0.0),
|
||||
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
|
||||
}
|
||||
@@ -38,8 +61,8 @@ def _serialize_lead(row: Any) -> dict[str, Any]:
|
||||
"urgency": row["urgency"],
|
||||
"financing_posture": row["financing_posture"],
|
||||
"timeline_to_decision": row["timeline_to_decision"],
|
||||
"objections": row["objections"] or [],
|
||||
"motivations": row["motivations"] or [],
|
||||
"objections": _json_string_list(row["objections"]),
|
||||
"motivations": _json_string_list(row["motivations"]),
|
||||
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
|
||||
}
|
||||
|
||||
@@ -99,7 +122,7 @@ def _serialize_property_interest(row: Any) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
async def get_client_360(conn: Any, person_id: str) -> dict[str, Any] | None:
|
||||
async def get_client_360(conn: Any, tenant_id: str, person_id: str) -> dict[str, Any] | None:
|
||||
"""
|
||||
Aggregate a full Client360Snapshot for a given person_id.
|
||||
This is a read model — derived from canonical tables, never primary truth.
|
||||
@@ -111,8 +134,10 @@ async def get_client_360(conn: Any, person_id: str) -> dict[str, Any] | None:
|
||||
buyer_type, persona_labels, source_confidence, created_at
|
||||
FROM crm_people
|
||||
WHERE person_id = $1::uuid
|
||||
AND tenant_id = $2
|
||||
""",
|
||||
person_id,
|
||||
tenant_id,
|
||||
)
|
||||
if not person_row:
|
||||
return None
|
||||
@@ -126,9 +151,12 @@ async def get_client_360(conn: Any, person_id: str) -> dict[str, Any] | None:
|
||||
FROM crm_accounts ca
|
||||
INNER JOIN crm_leads cl ON cl.account_id = ca.account_id
|
||||
WHERE cl.person_id = $1::uuid
|
||||
AND cl.tenant_id = $2
|
||||
AND ca.tenant_id = $2
|
||||
LIMIT 5
|
||||
""",
|
||||
person_id,
|
||||
tenant_id,
|
||||
)
|
||||
account_links = [
|
||||
{
|
||||
@@ -147,10 +175,12 @@ async def get_client_360(conn: Any, person_id: str) -> dict[str, Any] | None:
|
||||
timeline_to_decision, objections, motivations, created_at
|
||||
FROM crm_leads
|
||||
WHERE person_id = $1::uuid
|
||||
AND tenant_id = $2
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
person_id,
|
||||
tenant_id,
|
||||
)
|
||||
lead = _serialize_lead(lead_row) if lead_row else None
|
||||
|
||||
@@ -162,10 +192,13 @@ async def get_client_360(conn: Any, person_id: str) -> dict[str, Any] | None:
|
||||
FROM crm_opportunities co
|
||||
INNER JOIN crm_leads cl ON cl.lead_id = co.lead_id
|
||||
WHERE cl.person_id = $1::uuid
|
||||
AND cl.tenant_id = $2
|
||||
AND co.tenant_id = $2
|
||||
ORDER BY co.updated_at DESC
|
||||
LIMIT 5
|
||||
""",
|
||||
person_id,
|
||||
tenant_id,
|
||||
)
|
||||
active_opportunities = [_serialize_opportunity(r) for r in opp_rows]
|
||||
|
||||
@@ -175,10 +208,12 @@ async def get_client_360(conn: Any, person_id: str) -> dict[str, Any] | None:
|
||||
SELECT interaction_id, channel, interaction_type, happened_at, summary
|
||||
FROM intel_interactions
|
||||
WHERE person_id = $1::uuid
|
||||
AND tenant_id = $2
|
||||
ORDER BY happened_at DESC
|
||||
LIMIT 10
|
||||
""",
|
||||
person_id,
|
||||
tenant_id,
|
||||
)
|
||||
recent_interactions = [_serialize_interaction(r) for r in interaction_rows]
|
||||
|
||||
@@ -189,10 +224,12 @@ async def get_client_360(conn: Any, person_id: str) -> dict[str, Any] | None:
|
||||
budget_min, budget_max, priority
|
||||
FROM crm_property_interests
|
||||
WHERE person_id = $1::uuid
|
||||
AND tenant_id = $2
|
||||
ORDER BY priority ASC, interest_id ASC
|
||||
LIMIT 10
|
||||
""",
|
||||
person_id,
|
||||
tenant_id,
|
||||
)
|
||||
property_interests = [_serialize_property_interest(r) for r in interest_rows]
|
||||
|
||||
@@ -202,11 +239,13 @@ async def get_client_360(conn: Any, person_id: str) -> dict[str, Any] | None:
|
||||
SELECT reminder_id, reminder_type, title, due_at, status, priority
|
||||
FROM intel_reminders
|
||||
WHERE person_id = $1::uuid
|
||||
AND tenant_id = $2
|
||||
AND status IN ('pending', 'snoozed')
|
||||
ORDER BY due_at ASC NULLS LAST
|
||||
LIMIT 10
|
||||
""",
|
||||
person_id,
|
||||
tenant_id,
|
||||
)
|
||||
tasks = [_serialize_reminder(r) for r in task_rows]
|
||||
|
||||
@@ -216,8 +255,10 @@ async def get_client_360(conn: Any, person_id: str) -> dict[str, Any] | None:
|
||||
SELECT score_type, current_value, computed_at, reasoning
|
||||
FROM intel_qd_scores
|
||||
WHERE person_id = $1::uuid
|
||||
AND tenant_id = $2
|
||||
""",
|
||||
person_id,
|
||||
tenant_id,
|
||||
)
|
||||
qd_overview = {r["score_type"]: _serialize_qd_score(r) for r in qd_rows}
|
||||
|
||||
@@ -262,6 +303,7 @@ async def get_client_360(conn: Any, person_id: str) -> dict[str, Any] | None:
|
||||
|
||||
async def get_contact_list(
|
||||
conn: Any,
|
||||
tenant_id: str,
|
||||
search: str | None = None,
|
||||
buyer_type: str | None = None,
|
||||
status: str | None = None,
|
||||
@@ -272,8 +314,8 @@ async def get_contact_list(
|
||||
Paginated contact list with lead status and QD summary.
|
||||
Implements the 'summary query' pattern from Doc 09.
|
||||
"""
|
||||
clauses: list[str] = ["1=1"]
|
||||
params: list[Any] = []
|
||||
clauses: list[str] = ["p.tenant_id = $1"]
|
||||
params: list[Any] = [tenant_id]
|
||||
|
||||
if search:
|
||||
params.append(f"%{search}%")
|
||||
@@ -310,14 +352,15 @@ async def get_contact_list(
|
||||
COALESCE(qs.intent_value, 0.0) AS intent_score,
|
||||
COALESCE(qs.engagement_value, qs.intent_value, 0.0) AS engagement_score,
|
||||
COALESCE(qs.urgency_value, 0.0) AS urgency_score,
|
||||
(SELECT COUNT(*) FROM intel_interactions ii WHERE ii.person_id = p.person_id) AS interaction_count,
|
||||
(SELECT MAX(happened_at) FROM intel_interactions ii WHERE ii.person_id = p.person_id) AS last_interaction_at,
|
||||
(SELECT COUNT(*) FROM intel_reminders ir WHERE ir.person_id = p.person_id AND ir.status = 'pending') AS pending_tasks
|
||||
(SELECT COUNT(*) FROM intel_interactions ii WHERE ii.person_id = p.person_id AND ii.tenant_id = p.tenant_id) AS interaction_count,
|
||||
(SELECT MAX(happened_at) FROM intel_interactions ii WHERE ii.person_id = p.person_id AND ii.tenant_id = p.tenant_id) AS last_interaction_at,
|
||||
(SELECT COUNT(*) FROM intel_reminders ir WHERE ir.person_id = p.person_id AND ir.tenant_id = p.tenant_id AND ir.status = 'pending') AS pending_tasks
|
||||
FROM crm_people p
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT lead_id, status, budget_band, urgency
|
||||
FROM crm_leads
|
||||
WHERE person_id = p.person_id
|
||||
AND tenant_id = p.tenant_id
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
) cl ON TRUE
|
||||
@@ -325,6 +368,7 @@ async def get_contact_list(
|
||||
SELECT project_name
|
||||
FROM crm_property_interests
|
||||
WHERE person_id = p.person_id
|
||||
AND tenant_id = p.tenant_id
|
||||
ORDER BY priority ASC, created_at DESC
|
||||
LIMIT 1
|
||||
) pi ON TRUE
|
||||
@@ -335,6 +379,7 @@ async def get_contact_list(
|
||||
MAX(CASE WHEN score_type = 'urgency_score' THEN current_value END) AS urgency_value
|
||||
FROM intel_qd_scores
|
||||
WHERE person_id = p.person_id
|
||||
AND tenant_id = p.tenant_id
|
||||
) qs ON TRUE
|
||||
{where}
|
||||
ORDER BY last_interaction_at DESC NULLS LAST, p.created_at DESC
|
||||
@@ -344,7 +389,7 @@ async def get_contact_list(
|
||||
count_query = f"""
|
||||
SELECT COUNT(*)
|
||||
FROM crm_people p
|
||||
LEFT JOIN crm_leads cl ON cl.person_id = p.person_id
|
||||
LEFT JOIN crm_leads cl ON cl.person_id = p.person_id AND cl.tenant_id = p.tenant_id
|
||||
{where}
|
||||
"""
|
||||
|
||||
|
||||
@@ -202,6 +202,7 @@ def create_import_batch_record(
|
||||
mapping_manifest: dict[str, Any],
|
||||
source_system: str = "csv_upload",
|
||||
uploaded_by_id: str | None = None,
|
||||
tenant_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Build the workflow_import_batches record payload.
|
||||
@@ -216,6 +217,7 @@ def create_import_batch_record(
|
||||
"mapped_count": mapping_manifest.get("mapped_count", 0),
|
||||
"unresolved_count": mapping_manifest.get("unmapped_count", 0),
|
||||
"uploaded_by": uploaded_by_id,
|
||||
"tenant_id": tenant_id,
|
||||
"lifecycle": "parsed",
|
||||
"mapping_manifest": mapping_manifest,
|
||||
"created_at": now,
|
||||
@@ -230,15 +232,16 @@ async def persist_import_batch(conn: Any, batch: dict[str, Any]) -> str:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO workflow_import_batches (
|
||||
batch_id, source_system, uploaded_filename, mime_type, row_count,
|
||||
batch_id, tenant_id, source_system, uploaded_filename, mime_type, row_count,
|
||||
mapped_count, unresolved_count, uploaded_by, lifecycle, mapping_manifest,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
$1::uuid, $2, $3, $4, $5, $6, $7,
|
||||
$8::uuid, $9::import_lifecycle, $10::jsonb, NOW(), NOW()
|
||||
$1::uuid, $2, $3, $4, $5, $6, $7, $8,
|
||||
$9::uuid, $10::import_lifecycle, $11::jsonb, NOW(), NOW()
|
||||
)
|
||||
""",
|
||||
batch["batch_id"],
|
||||
batch["tenant_id"],
|
||||
batch["source_system"],
|
||||
batch.get("uploaded_filename", "unknown.csv"),
|
||||
batch.get("mime_type", "text/csv"),
|
||||
@@ -253,7 +256,7 @@ async def persist_import_batch(conn: Any, batch: dict[str, Any]) -> str:
|
||||
|
||||
|
||||
async def persist_proposals_as_workflow_actions(
|
||||
conn: Any, proposals: list[dict[str, Any]]
|
||||
conn: Any, proposals: list[dict[str, Any]], tenant_id: str
|
||||
) -> int:
|
||||
"""
|
||||
Insert proposals into workflow_actions table for human review.
|
||||
@@ -264,15 +267,16 @@ async def persist_proposals_as_workflow_actions(
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO workflow_actions (
|
||||
action_id, action_type, target_domain, proposal_payload,
|
||||
action_id, tenant_id, action_type, target_domain, proposal_payload,
|
||||
reasoning_summary, confidence, status, approval_required,
|
||||
created_by_agent, created_at, updated_at
|
||||
) VALUES (
|
||||
$1::uuid, 'import_proposal', 'crm', $2::jsonb,
|
||||
$3, $4, 'pending'::wf_status, $5, 'ingest_service', NOW(), NOW()
|
||||
$1::uuid, $2, 'import_proposal', 'crm', $3::jsonb,
|
||||
$4, $5, 'pending'::wf_status, $6, 'ingest_service', NOW(), NOW()
|
||||
)
|
||||
""",
|
||||
p["proposal_id"],
|
||||
tenant_id,
|
||||
json.dumps(p),
|
||||
f"Import row {p['row_number']}: {p['canonical_payload'].get('full_name', 'unknown')}",
|
||||
p["confidence"],
|
||||
|
||||
212
backend/tests/test_auth_tenant_contract.py
Normal file
212
backend/tests/test_auth_tenant_contract.py
Normal file
@@ -0,0 +1,212 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
from jose import jwt
|
||||
|
||||
os.environ.setdefault("VELOCITY_JWT_SECRET", "test-secret")
|
||||
|
||||
from backend.auth.dependencies import UserPrincipal
|
||||
from backend.auth import service as auth_service
|
||||
|
||||
auth_service.verify_password = lambda plain, hashed: plain == hashed
|
||||
|
||||
|
||||
class AppState:
|
||||
def __init__(self, pool: Any) -> None:
|
||||
self.db_pool = pool
|
||||
self._auth_user_directory_schema_ready = False
|
||||
|
||||
|
||||
class AppStub:
|
||||
def __init__(self, pool: Any) -> None:
|
||||
self.state = AppState(pool)
|
||||
|
||||
|
||||
class RequestStub:
|
||||
def __init__(self, pool: Any) -> None:
|
||||
self.app = AppStub(pool)
|
||||
|
||||
|
||||
class FakeConn:
|
||||
def __init__(self) -> None:
|
||||
password_hash = "velocity-demo-password"
|
||||
self.users: dict[str, dict[str, Any]] = {
|
||||
"user-alpha": {
|
||||
"id": "00000000-0000-0000-0000-000000000001",
|
||||
"email": "alpha@example.com",
|
||||
"password_hash": password_hash,
|
||||
"role": "ADMIN",
|
||||
"tenant_id": "tenant_alpha",
|
||||
"full_name": "Alpha Operator",
|
||||
"avatar_url": "/assets/profile_avatars/alpha.png",
|
||||
"is_active": True,
|
||||
},
|
||||
"user-beta": {
|
||||
"id": "00000000-0000-0000-0000-000000000002",
|
||||
"email": "beta@example.com",
|
||||
"password_hash": password_hash,
|
||||
"role": "ADMIN",
|
||||
"tenant_id": "tenant_beta",
|
||||
"full_name": "Beta Operator",
|
||||
"avatar_url": "/assets/profile_avatars/beta.png",
|
||||
"is_active": True,
|
||||
},
|
||||
"user-legacy": {
|
||||
"id": "00000000-0000-0000-0000-000000000003",
|
||||
"email": "legacy@example.com",
|
||||
"password_hash": password_hash,
|
||||
"role": "SENIOR_BROKER",
|
||||
"tenant_id": "",
|
||||
"full_name": "Legacy Tenant User",
|
||||
"avatar_url": None,
|
||||
"is_active": True,
|
||||
},
|
||||
}
|
||||
self.schema_ready = False
|
||||
|
||||
async def execute(self, query: str, *args):
|
||||
normalized = " ".join(query.strip().split())
|
||||
if normalized.startswith("ALTER TABLE users_and_roles ADD COLUMN IF NOT EXISTS tenant_id TEXT"):
|
||||
for user in self.users.values():
|
||||
user.setdefault("tenant_id", "")
|
||||
return "ALTER TABLE"
|
||||
if normalized.startswith("UPDATE users_and_roles SET tenant_id = $1 WHERE tenant_id IS NULL OR tenant_id = ''"):
|
||||
for user in self.users.values():
|
||||
if not user.get("tenant_id"):
|
||||
user["tenant_id"] = args[0]
|
||||
return "UPDATE"
|
||||
if normalized.startswith("ALTER TABLE users_and_roles ALTER COLUMN tenant_id SET DEFAULT"):
|
||||
return "ALTER TABLE"
|
||||
if normalized.startswith("ALTER TABLE users_and_roles ALTER COLUMN tenant_id SET NOT NULL"):
|
||||
self.schema_ready = True
|
||||
return "ALTER TABLE"
|
||||
if normalized.startswith("CREATE INDEX IF NOT EXISTS idx_users_tenant_active ON users_and_roles (tenant_id, is_active)"):
|
||||
return "CREATE INDEX"
|
||||
if normalized.startswith("UPDATE users_and_roles SET avatar_url = $2 WHERE id = $1::uuid AND tenant_id = $3"):
|
||||
for user in self.users.values():
|
||||
if user["id"] == args[0] and user["tenant_id"] == args[2]:
|
||||
user["avatar_url"] = args[1]
|
||||
return "UPDATE 1"
|
||||
return "UPDATE 0"
|
||||
raise AssertionError(f"Unexpected execute query: {query}")
|
||||
|
||||
async def fetchrow(self, query: str, *args):
|
||||
normalized = " ".join(query.strip().split())
|
||||
if "FROM users_and_roles" not in normalized:
|
||||
raise AssertionError(f"Unexpected fetchrow query: {query}")
|
||||
if "password_hash" in normalized:
|
||||
email, tenant_fallback = args
|
||||
for user in self.users.values():
|
||||
if user["email"] == email and user["is_active"]:
|
||||
return {
|
||||
"id": user["id"],
|
||||
"role": user["role"],
|
||||
"password_hash": user["password_hash"],
|
||||
"tenant_id": user["tenant_id"] or tenant_fallback,
|
||||
}
|
||||
return None
|
||||
if "WHERE id = $1::uuid AND COALESCE(NULLIF(tenant_id, ''), $2) = $2" in normalized:
|
||||
user_id, tenant_id = args
|
||||
for user in self.users.values():
|
||||
resolved_tenant = user["tenant_id"] or tenant_id
|
||||
if user["id"] == user_id and resolved_tenant == tenant_id:
|
||||
return {
|
||||
"full_name": user["full_name"],
|
||||
"email": user["email"],
|
||||
"avatar_url": user["avatar_url"],
|
||||
"tenant_id": resolved_tenant,
|
||||
}
|
||||
return None
|
||||
raise AssertionError(f"Unexpected fetchrow query: {query}")
|
||||
|
||||
async def fetch(self, query: str, *args):
|
||||
normalized = " ".join(query.strip().split())
|
||||
if "FROM users_and_roles" not in normalized:
|
||||
raise AssertionError(f"Unexpected fetch query: {query}")
|
||||
tenant_fallback, tenant_id = args
|
||||
rows = []
|
||||
for user in self.users.values():
|
||||
resolved_tenant = user["tenant_id"] or tenant_fallback
|
||||
if user["is_active"] and resolved_tenant == tenant_id:
|
||||
rows.append(
|
||||
{
|
||||
"user_id": user["id"],
|
||||
"role": user["role"],
|
||||
"tenant_id": resolved_tenant,
|
||||
"full_name": user["full_name"],
|
||||
"email": user["email"],
|
||||
"avatar_url": user["avatar_url"],
|
||||
}
|
||||
)
|
||||
rows.sort(key=lambda row: (row["full_name"] or row["email"] or row["user_id"]))
|
||||
return rows
|
||||
|
||||
|
||||
class FakePool:
|
||||
def __init__(self) -> None:
|
||||
self.conn = FakeConn()
|
||||
|
||||
@asynccontextmanager
|
||||
async def acquire(self):
|
||||
yield self.conn
|
||||
|
||||
|
||||
def _build_request() -> tuple[RequestStub, FakePool]:
|
||||
pool = FakePool()
|
||||
return RequestStub(pool), pool
|
||||
|
||||
|
||||
def test_login_mints_token_with_user_tenant_id() -> None:
|
||||
request, pool = _build_request()
|
||||
|
||||
response = asyncio.run(
|
||||
auth_service.login_with_directory(
|
||||
app=request.app,
|
||||
email="alpha@example.com",
|
||||
password="velocity-demo-password",
|
||||
)
|
||||
)
|
||||
|
||||
payload = jwt.get_unverified_claims(response["access_token"])
|
||||
assert payload["tenant_id"] == "tenant_alpha"
|
||||
assert pool.conn.schema_ready is True
|
||||
|
||||
|
||||
def test_login_backfills_legacy_users_to_default_tenant_before_minting() -> None:
|
||||
request, pool = _build_request()
|
||||
|
||||
response = asyncio.run(
|
||||
auth_service.login_with_directory(
|
||||
app=request.app,
|
||||
email="legacy@example.com",
|
||||
password="velocity-demo-password",
|
||||
)
|
||||
)
|
||||
|
||||
payload = jwt.get_unverified_claims(response["access_token"])
|
||||
assert payload["tenant_id"] == "tenant_velocity"
|
||||
assert pool.conn.users["user-legacy"]["tenant_id"] == "tenant_velocity"
|
||||
|
||||
|
||||
def test_auth_me_returns_profile_for_authenticated_tenant() -> None:
|
||||
request, _pool = _build_request()
|
||||
user = UserPrincipal("00000000-0000-0000-0000-000000000001", "ADMIN", "tenant_alpha")
|
||||
|
||||
response = asyncio.run(auth_service.read_authenticated_user_profile(app=request.app, user=user))
|
||||
|
||||
assert response["tenant_id"] == "tenant_alpha"
|
||||
assert response["email"] == "alpha@example.com"
|
||||
|
||||
|
||||
def test_auth_users_are_scoped_to_authenticated_tenant() -> None:
|
||||
request, _pool = _build_request()
|
||||
user = UserPrincipal("00000000-0000-0000-0000-000000000001", "ADMIN", "tenant_alpha")
|
||||
|
||||
users = asyncio.run(auth_service.list_tenant_users(app=request.app, user=user))
|
||||
|
||||
assert [user["email"] for user in users] == ["alpha@example.com"]
|
||||
assert all(user["tenant_id"] == "tenant_alpha" for user in users)
|
||||
162
backend/tests/test_canonical_crm_auth.py
Normal file
162
backend/tests/test_canonical_crm_auth.py
Normal file
@@ -0,0 +1,162 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
os.environ.setdefault("VELOCITY_JWT_SECRET", "test-secret")
|
||||
|
||||
from backend.api import routes_crm_imports
|
||||
from backend.auth.dependencies import UserPrincipal, get_current_user
|
||||
|
||||
|
||||
class FakeConn:
|
||||
async def execute(self, query: str, *args):
|
||||
return "OK"
|
||||
|
||||
|
||||
class FakePool:
|
||||
def __init__(self) -> None:
|
||||
self.conn = FakeConn()
|
||||
|
||||
@asynccontextmanager
|
||||
async def acquire(self):
|
||||
yield self.conn
|
||||
|
||||
|
||||
def _build_app(*, authenticated: bool) -> TestClient:
|
||||
app = FastAPI()
|
||||
app.state.db_pool = FakePool()
|
||||
app.include_router(routes_crm_imports.router, prefix="/api")
|
||||
if authenticated:
|
||||
app.dependency_overrides[get_current_user] = lambda: UserPrincipal(
|
||||
"00000000-0000-0000-0000-000000000001",
|
||||
"ADMIN",
|
||||
"tenant_alpha",
|
||||
)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_canonical_crm_routes_require_authentication() -> None:
|
||||
client = _build_app(authenticated=False)
|
||||
|
||||
response = client.get("/api/crm/contacts")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == "Missing or malformed Authorization header."
|
||||
|
||||
|
||||
def test_canonical_crm_task_routes_require_authentication() -> None:
|
||||
client = _build_app(authenticated=False)
|
||||
|
||||
response = client.get("/api/crm/tasks")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == "Missing or malformed Authorization header."
|
||||
|
||||
|
||||
def test_canonical_crm_task_write_routes_require_authentication() -> None:
|
||||
client = _build_app(authenticated=False)
|
||||
|
||||
response = client.patch(
|
||||
"/api/crm/tasks/33333333-3333-3333-3333-333333333333",
|
||||
json={"status": "done"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == "Missing or malformed Authorization header."
|
||||
|
||||
|
||||
def test_canonical_crm_lead_stage_write_routes_require_authentication() -> None:
|
||||
client = _build_app(authenticated=False)
|
||||
|
||||
response = client.patch(
|
||||
"/api/crm/leads/22222222-2222-2222-2222-222222222222/stage",
|
||||
json={"status": "qualified"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == "Missing or malformed Authorization header."
|
||||
|
||||
|
||||
def test_canonical_crm_opportunity_write_routes_require_authentication() -> None:
|
||||
client = _build_app(authenticated=False)
|
||||
|
||||
response = client.patch(
|
||||
"/api/crm/opportunities/55555555-5555-5555-5555-555555555555",
|
||||
json={"stage": "negotiation"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == "Missing or malformed Authorization header."
|
||||
|
||||
|
||||
def test_canonical_crm_import_upload_requires_authentication() -> None:
|
||||
client = _build_app(authenticated=False)
|
||||
|
||||
response = client.post(
|
||||
"/api/crm/imports",
|
||||
params={"source_system": "csv_upload"},
|
||||
files={"file": ("contacts.csv", io.BytesIO(b"name,phone\nAmina,+9715000\n"), "text/csv")},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == "Missing or malformed Authorization header."
|
||||
|
||||
|
||||
def test_canonical_crm_contacts_can_be_read_when_authenticated(monkeypatch) -> None:
|
||||
client = _build_app(authenticated=True)
|
||||
|
||||
async def fake_get_contact_list(
|
||||
conn: Any,
|
||||
tenant_id: str,
|
||||
search: str | None = None,
|
||||
buyer_type: str | None = None,
|
||||
status: str | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
assert tenant_id == "tenant_alpha"
|
||||
assert search is None
|
||||
assert buyer_type is None
|
||||
assert status is None
|
||||
return {
|
||||
"contacts": [
|
||||
{
|
||||
"person_id": "11111111-1111-1111-1111-111111111111",
|
||||
"full_name": "Amina Rahman",
|
||||
"primary_email": "amina@example.com",
|
||||
"primary_phone": "+971500000001",
|
||||
"buyer_type": "high_intent",
|
||||
"lead_id": "22222222-2222-2222-2222-222222222222",
|
||||
"legacy_li_id": None,
|
||||
"lead_status": "qualified",
|
||||
"budget_band": "AED 12M",
|
||||
"urgency": "high",
|
||||
"primary_interest": "Marina Penthouse",
|
||||
"intent_score": 0.94,
|
||||
"engagement_score": 0.91,
|
||||
"urgency_score": 0.88,
|
||||
"interaction_count": 6,
|
||||
"last_interaction_at": "2026-04-22T10:00:00+00:00",
|
||||
"pending_tasks": 1,
|
||||
"created_at": "2026-04-21T10:00:00+00:00",
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(routes_crm_imports, "get_contact_list", fake_get_contact_list)
|
||||
|
||||
response = client.get("/api/crm/contacts")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()["data"]
|
||||
assert payload["total"] == 1
|
||||
assert payload["contacts"][0]["full_name"] == "Amina Rahman"
|
||||
517
backend/tests/test_canonical_crm_tenant_scoping.py
Normal file
517
backend/tests/test_canonical_crm_tenant_scoping.py
Normal file
@@ -0,0 +1,517 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
os.environ.setdefault("VELOCITY_JWT_SECRET", "test-secret")
|
||||
|
||||
from backend.api import routes_crm_imports
|
||||
from backend.auth.dependencies import UserPrincipal, get_current_user
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class FakeConn:
|
||||
def __init__(self) -> None:
|
||||
self.people: dict[str, dict[str, Any]] = {
|
||||
"11111111-1111-1111-1111-111111111111": {
|
||||
"person_id": "11111111-1111-1111-1111-111111111111",
|
||||
"tenant_id": "tenant_alpha",
|
||||
"full_name": "Amina Rahman",
|
||||
"primary_email": "amina@example.com",
|
||||
"primary_phone": "+971500000001",
|
||||
"secondary_phone": None,
|
||||
"buyer_type": "high_intent",
|
||||
"persona_labels": [],
|
||||
"source_confidence": 1.0,
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
}
|
||||
}
|
||||
self.leads: dict[str, dict[str, Any]] = {
|
||||
"22222222-2222-2222-2222-222222222222": {
|
||||
"lead_id": "22222222-2222-2222-2222-222222222222",
|
||||
"tenant_id": "tenant_alpha",
|
||||
"person_id": "11111111-1111-1111-1111-111111111111",
|
||||
"status": "new",
|
||||
"budget_band": "AED 12M",
|
||||
"urgency": "high",
|
||||
}
|
||||
}
|
||||
self.reminders: dict[str, dict[str, Any]] = {
|
||||
"33333333-3333-3333-3333-333333333333": {
|
||||
"reminder_id": "33333333-3333-3333-3333-333333333333",
|
||||
"tenant_id": "tenant_alpha",
|
||||
"person_id": "11111111-1111-1111-1111-111111111111",
|
||||
"lead_id": "22222222-2222-2222-2222-222222222222",
|
||||
"reminder_type": "follow_up",
|
||||
"title": "Call marina lead",
|
||||
"notes": "Confirm visit time",
|
||||
"status": "pending",
|
||||
"priority": "high",
|
||||
"due_at": _now(),
|
||||
},
|
||||
"44444444-4444-4444-4444-444444444444": {
|
||||
"reminder_id": "44444444-4444-4444-4444-444444444444",
|
||||
"tenant_id": "tenant_beta",
|
||||
"person_id": "99999999-9999-9999-9999-999999999999",
|
||||
"lead_id": None,
|
||||
"reminder_type": "follow_up",
|
||||
"title": "Cross-tenant task",
|
||||
"notes": "Should not leak",
|
||||
"status": "pending",
|
||||
"priority": "normal",
|
||||
"due_at": _now(),
|
||||
},
|
||||
}
|
||||
self.opportunities: dict[str, dict[str, Any]] = {
|
||||
"55555555-5555-5555-5555-555555555555": {
|
||||
"opportunity_id": "55555555-5555-5555-5555-555555555555",
|
||||
"tenant_id": "tenant_alpha",
|
||||
"lead_id": "22222222-2222-2222-2222-222222222222",
|
||||
"stage": "proposal",
|
||||
"value": 12000000.0,
|
||||
"probability": 60,
|
||||
"expected_close_date": None,
|
||||
"next_action": "Share proposal",
|
||||
"notes": "Initial terms shared",
|
||||
"project_name": "Marina Residences",
|
||||
},
|
||||
"66666666-6666-6666-6666-666666666666": {
|
||||
"opportunity_id": "66666666-6666-6666-6666-666666666666",
|
||||
"tenant_id": "tenant_beta",
|
||||
"lead_id": "99999999-9999-9999-9999-999999999999",
|
||||
"stage": "proposal",
|
||||
"value": 7000000.0,
|
||||
"probability": 50,
|
||||
"expected_close_date": None,
|
||||
"next_action": "Cross tenant",
|
||||
"notes": None,
|
||||
"project_name": None,
|
||||
},
|
||||
}
|
||||
self.stage_history: list[dict[str, Any]] = []
|
||||
|
||||
async def execute(self, query: str, *args):
|
||||
normalized = " ".join(query.strip().split())
|
||||
if normalized.startswith("ALTER TABLE ") or normalized.startswith("CREATE INDEX IF NOT EXISTS "):
|
||||
return "OK"
|
||||
if normalized.startswith("UPDATE ") and " SET tenant_id = $1 " in f" {normalized} ":
|
||||
return "UPDATE"
|
||||
if normalized.startswith("INSERT INTO crm_people"):
|
||||
self.people[args[0]] = {
|
||||
"person_id": args[0],
|
||||
"tenant_id": args[1],
|
||||
"full_name": args[2],
|
||||
"primary_email": args[3],
|
||||
"primary_phone": args[4],
|
||||
"secondary_phone": None,
|
||||
"buyer_type": args[5],
|
||||
"persona_labels": [],
|
||||
"source_confidence": 1.0,
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
}
|
||||
return "INSERT 1"
|
||||
if normalized.startswith("INSERT INTO crm_leads"):
|
||||
self.leads[args[0]] = {
|
||||
"lead_id": args[0],
|
||||
"tenant_id": args[1],
|
||||
"person_id": args[2],
|
||||
}
|
||||
return "INSERT 1"
|
||||
if normalized.startswith("INSERT INTO crm_property_interests"):
|
||||
return "INSERT 1"
|
||||
if normalized.startswith("INSERT INTO intel_reminders"):
|
||||
self.reminders[args[0]] = {
|
||||
"reminder_id": args[0],
|
||||
"tenant_id": args[1],
|
||||
"person_id": args[2],
|
||||
"lead_id": args[3],
|
||||
"reminder_type": args[4],
|
||||
"title": args[5],
|
||||
"notes": args[6],
|
||||
"due_at": args[7],
|
||||
"status": "pending",
|
||||
"priority": args[8],
|
||||
}
|
||||
return "INSERT 1"
|
||||
if normalized.startswith("INSERT INTO crm_stage_history"):
|
||||
self.stage_history.append(
|
||||
{
|
||||
"history_id": args[0],
|
||||
"lead_id": args[1],
|
||||
"from_status": args[2],
|
||||
"to_status": args[3],
|
||||
"changed_by": args[4],
|
||||
"notes": args[5],
|
||||
}
|
||||
)
|
||||
return "INSERT 1"
|
||||
raise AssertionError(f"Unexpected execute query: {query}")
|
||||
|
||||
async def fetchrow(self, query: str, *args):
|
||||
normalized = " ".join(query.strip().split())
|
||||
if "FROM crm_people" in normalized and "WHERE person_id = $1::uuid AND tenant_id = $2" in normalized:
|
||||
row = self.people.get(args[0])
|
||||
return dict(row) if row and row["tenant_id"] == args[1] else None
|
||||
if "FROM crm_leads" in normalized and "WHERE lead_id = $1::uuid AND person_id = $2::uuid AND tenant_id = $3" in normalized:
|
||||
row = self.leads.get(args[0])
|
||||
return dict(row) if row and row["person_id"] == args[1] and row["tenant_id"] == args[2] else None
|
||||
if "FROM intel_reminders" in normalized and "WHERE reminder_id = $1::uuid AND tenant_id = $2" in normalized:
|
||||
row = self.reminders.get(args[0])
|
||||
return dict(row) if row and row["tenant_id"] == args[1] else None
|
||||
if normalized.startswith("UPDATE intel_reminders ir SET status ="):
|
||||
row = self.reminders.get(args[0])
|
||||
if not row or row["tenant_id"] != args[1]:
|
||||
return None
|
||||
row["status"] = args[2]
|
||||
if args[3] is not None:
|
||||
row["due_at"] = args[3]
|
||||
if args[4] is not None:
|
||||
row["notes"] = args[4]
|
||||
person = self.people[row["person_id"]]
|
||||
return {
|
||||
"reminder_id": row["reminder_id"],
|
||||
"reminder_type": row["reminder_type"],
|
||||
"title": row["title"],
|
||||
"notes": row["notes"],
|
||||
"due_at": row["due_at"],
|
||||
"status": row["status"],
|
||||
"priority": row["priority"],
|
||||
"person_id": person["person_id"],
|
||||
"full_name": person["full_name"],
|
||||
"primary_phone": person["primary_phone"],
|
||||
}
|
||||
if "FROM crm_leads cl" in normalized and "WHERE cl.lead_id = $1::uuid AND cl.tenant_id = $2" in normalized:
|
||||
row = self.leads.get(args[0])
|
||||
if not row or row["tenant_id"] != args[1]:
|
||||
return None
|
||||
person = self.people[row["person_id"]]
|
||||
return {
|
||||
"lead_id": row["lead_id"],
|
||||
"person_id": row["person_id"],
|
||||
"status": row["status"],
|
||||
"budget_band": row["budget_band"],
|
||||
"urgency": row["urgency"],
|
||||
"full_name": person["full_name"],
|
||||
"primary_phone": person["primary_phone"],
|
||||
}
|
||||
if normalized.startswith("UPDATE crm_leads SET status = $3::crm_lead_status, updated_at = NOW()"):
|
||||
row = self.leads.get(args[0])
|
||||
if not row or row["tenant_id"] != args[1]:
|
||||
return None
|
||||
row["status"] = args[2]
|
||||
return {
|
||||
"lead_id": row["lead_id"],
|
||||
"person_id": row["person_id"],
|
||||
"status": row["status"],
|
||||
"budget_band": row["budget_band"],
|
||||
"urgency": row["urgency"],
|
||||
}
|
||||
if "FROM crm_opportunities co" in normalized and "WHERE co.opportunity_id = $1::uuid AND co.tenant_id = $2" in normalized:
|
||||
row = self.opportunities.get(args[0])
|
||||
if not row or row["tenant_id"] != args[1]:
|
||||
return None
|
||||
lead = self.leads[row["lead_id"]]
|
||||
person = self.people[lead["person_id"]]
|
||||
return {
|
||||
"opportunity_id": row["opportunity_id"],
|
||||
"stage": row["stage"],
|
||||
"value": row["value"],
|
||||
"probability": row["probability"],
|
||||
"expected_close_date": row["expected_close_date"],
|
||||
"next_action": row["next_action"],
|
||||
"notes": row["notes"],
|
||||
"person_id": person["person_id"],
|
||||
"full_name": person["full_name"],
|
||||
"primary_phone": person["primary_phone"],
|
||||
"project_name": row["project_name"],
|
||||
}
|
||||
if normalized.startswith("WITH updated AS ( UPDATE crm_opportunities co SET stage ="):
|
||||
row = self.opportunities.get(args[0])
|
||||
if not row or row["tenant_id"] != args[1]:
|
||||
return None
|
||||
if args[2] is not None:
|
||||
row["stage"] = args[2]
|
||||
if args[3]:
|
||||
row["value"] = args[4]
|
||||
if args[5]:
|
||||
row["probability"] = args[6]
|
||||
if args[7]:
|
||||
row["expected_close_date"] = args[8]
|
||||
if args[9]:
|
||||
row["next_action"] = args[10]
|
||||
if args[11]:
|
||||
row["notes"] = args[12]
|
||||
lead = self.leads[row["lead_id"]]
|
||||
person = self.people[lead["person_id"]]
|
||||
return {
|
||||
"opportunity_id": row["opportunity_id"],
|
||||
"stage": row["stage"],
|
||||
"value": row["value"],
|
||||
"probability": row["probability"],
|
||||
"expected_close_date": row["expected_close_date"],
|
||||
"next_action": row["next_action"],
|
||||
"notes": row["notes"],
|
||||
"person_id": person["person_id"],
|
||||
"full_name": person["full_name"],
|
||||
"primary_phone": person["primary_phone"],
|
||||
"project_name": row["project_name"],
|
||||
}
|
||||
raise AssertionError(f"Unexpected fetchrow query: {query}")
|
||||
|
||||
async def fetch(self, query: str, *args):
|
||||
normalized = " ".join(query.strip().split())
|
||||
if "FROM intel_reminders ir" in normalized:
|
||||
tenant_id = args[0]
|
||||
status_filter = args[1] if len(args) >= 3 else None
|
||||
rows = []
|
||||
for reminder in self.reminders.values():
|
||||
if reminder["tenant_id"] != tenant_id:
|
||||
continue
|
||||
if status_filter and reminder["status"] != status_filter:
|
||||
continue
|
||||
person = self.people.get(reminder["person_id"])
|
||||
if not person or person["tenant_id"] != tenant_id:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"reminder_id": reminder["reminder_id"],
|
||||
"reminder_type": reminder["reminder_type"],
|
||||
"title": reminder["title"],
|
||||
"notes": reminder["notes"],
|
||||
"due_at": reminder["due_at"],
|
||||
"status": reminder["status"],
|
||||
"priority": reminder["priority"],
|
||||
"person_id": person["person_id"],
|
||||
"full_name": person["full_name"],
|
||||
"primary_phone": person["primary_phone"],
|
||||
}
|
||||
)
|
||||
return rows
|
||||
raise AssertionError(f"Unexpected fetch query: {query}")
|
||||
|
||||
|
||||
class FakePool:
|
||||
def __init__(self) -> None:
|
||||
self.conn = FakeConn()
|
||||
|
||||
@asynccontextmanager
|
||||
async def acquire(self):
|
||||
yield self.conn
|
||||
|
||||
|
||||
def _build_shared_app(pool: FakePool, tenant_id: str) -> TestClient:
|
||||
app = FastAPI()
|
||||
app.state.db_pool = pool
|
||||
app.include_router(routes_crm_imports.router, prefix="/api")
|
||||
app.dependency_overrides[get_current_user] = lambda: UserPrincipal(
|
||||
"00000000-0000-0000-0000-000000000001",
|
||||
"ADMIN",
|
||||
tenant_id,
|
||||
)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_canonical_contact_list_receives_authenticated_tenant(monkeypatch) -> None:
|
||||
pool = FakePool()
|
||||
client = _build_shared_app(pool, "tenant_alpha")
|
||||
|
||||
async def fake_get_contact_list(
|
||||
conn: Any,
|
||||
tenant_id: str,
|
||||
search: str | None = None,
|
||||
buyer_type: str | None = None,
|
||||
status: str | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
assert tenant_id == "tenant_alpha"
|
||||
return {"contacts": [], "total": 0, "limit": limit, "offset": offset}
|
||||
|
||||
monkeypatch.setattr(routes_crm_imports, "get_contact_list", fake_get_contact_list)
|
||||
|
||||
response = client.get("/api/crm/contacts")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["data"]["total"] == 0
|
||||
|
||||
|
||||
def test_canonical_task_routes_are_scoped_to_authenticated_tenant() -> None:
|
||||
pool = FakePool()
|
||||
tenant_alpha = _build_shared_app(pool, "tenant_alpha")
|
||||
tenant_beta = _build_shared_app(pool, "tenant_beta")
|
||||
|
||||
alpha_response = tenant_alpha.get("/api/crm/tasks")
|
||||
beta_response = tenant_beta.get("/api/crm/tasks")
|
||||
|
||||
assert alpha_response.status_code == 200
|
||||
assert len(alpha_response.json()["data"]) == 1
|
||||
assert alpha_response.json()["data"][0]["title"] == "Call marina lead"
|
||||
|
||||
assert beta_response.status_code == 200
|
||||
assert beta_response.json()["data"] == []
|
||||
|
||||
|
||||
def test_create_contact_persists_authenticated_tenant_on_canonical_records() -> None:
|
||||
pool = FakePool()
|
||||
client = _build_shared_app(pool, "tenant_alpha")
|
||||
|
||||
response = client.post(
|
||||
"/api/crm/contacts",
|
||||
json={
|
||||
"full_name": "New Canonical Contact",
|
||||
"primary_phone": "+971500000010",
|
||||
"budget_band": "AED 8M",
|
||||
"project_name": "Skyline",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
person_id = response.json()["data"]["person_id"]
|
||||
created_person = pool.conn.people[person_id]
|
||||
assert created_person["tenant_id"] == "tenant_alpha"
|
||||
|
||||
assert any(lead["tenant_id"] == "tenant_alpha" and lead["person_id"] == person_id for lead in pool.conn.leads.values())
|
||||
|
||||
|
||||
def test_create_task_rejects_cross_tenant_person_reference() -> None:
|
||||
pool = FakePool()
|
||||
client = _build_shared_app(pool, "tenant_beta")
|
||||
|
||||
response = client.post(
|
||||
"/api/crm/tasks",
|
||||
json={
|
||||
"person_id": "11111111-1111-1111-1111-111111111111",
|
||||
"lead_id": "22222222-2222-2222-2222-222222222222",
|
||||
"title": "Cross tenant task",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Contact '11111111-1111-1111-1111-111111111111' not found."
|
||||
|
||||
|
||||
def test_update_task_marks_done_for_authenticated_tenant() -> None:
|
||||
pool = FakePool()
|
||||
client = _build_shared_app(pool, "tenant_alpha")
|
||||
|
||||
response = client.patch(
|
||||
"/api/crm/tasks/33333333-3333-3333-3333-333333333333",
|
||||
json={"status": "done"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["data"]["status"] == "done"
|
||||
assert payload["meta"]["previous_status"] == "pending"
|
||||
assert payload["meta"]["changed"] is True
|
||||
assert pool.conn.reminders["33333333-3333-3333-3333-333333333333"]["status"] == "done"
|
||||
|
||||
|
||||
def test_update_task_marks_confirmed_for_authenticated_tenant() -> None:
|
||||
pool = FakePool()
|
||||
client = _build_shared_app(pool, "tenant_alpha")
|
||||
|
||||
response = client.patch(
|
||||
"/api/crm/tasks/33333333-3333-3333-3333-333333333333",
|
||||
json={"status": "confirmed"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["data"]["status"] == "confirmed"
|
||||
assert payload["meta"]["previous_status"] == "pending"
|
||||
assert payload["meta"]["changed"] is True
|
||||
assert pool.conn.reminders["33333333-3333-3333-3333-333333333333"]["status"] == "confirmed"
|
||||
|
||||
|
||||
def test_update_task_rejects_cross_tenant_task_reference() -> None:
|
||||
pool = FakePool()
|
||||
client = _build_shared_app(pool, "tenant_beta")
|
||||
|
||||
response = client.patch(
|
||||
"/api/crm/tasks/33333333-3333-3333-3333-333333333333",
|
||||
json={"status": "done"},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Task '33333333-3333-3333-3333-333333333333' not found."
|
||||
|
||||
|
||||
def test_update_lead_stage_records_canonical_stage_history() -> None:
|
||||
pool = FakePool()
|
||||
client = _build_shared_app(pool, "tenant_alpha")
|
||||
|
||||
response = client.patch(
|
||||
"/api/crm/leads/22222222-2222-2222-2222-222222222222/stage",
|
||||
json={"status": "qualified", "notes": "Advanced from iPad Oracle pipeline."},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["data"]["status"] == "qualified"
|
||||
assert payload["meta"]["previous_status"] == "new"
|
||||
assert payload["meta"]["changed"] is True
|
||||
assert pool.conn.leads["22222222-2222-2222-2222-222222222222"]["status"] == "qualified"
|
||||
assert len(pool.conn.stage_history) == 1
|
||||
assert pool.conn.stage_history[0]["from_status"] == "new"
|
||||
assert pool.conn.stage_history[0]["to_status"] == "qualified"
|
||||
|
||||
|
||||
def test_update_lead_stage_rejects_cross_tenant_reference() -> None:
|
||||
pool = FakePool()
|
||||
client = _build_shared_app(pool, "tenant_beta")
|
||||
|
||||
response = client.patch(
|
||||
"/api/crm/leads/22222222-2222-2222-2222-222222222222/stage",
|
||||
json={"status": "qualified"},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Lead '22222222-2222-2222-2222-222222222222' not found."
|
||||
|
||||
|
||||
def test_update_opportunity_mutates_canonical_deal_for_authenticated_tenant() -> None:
|
||||
pool = FakePool()
|
||||
client = _build_shared_app(pool, "tenant_alpha")
|
||||
|
||||
response = client.patch(
|
||||
"/api/crm/opportunities/55555555-5555-5555-5555-555555555555",
|
||||
json={
|
||||
"stage": "negotiation",
|
||||
"probability": 75,
|
||||
"next_action": "Schedule commercial review",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["data"]["stage"] == "negotiation"
|
||||
assert payload["data"]["probability"] == 75
|
||||
assert payload["data"]["next_action"] == "Schedule commercial review"
|
||||
assert payload["data"]["client_name"] == "Amina Rahman"
|
||||
assert payload["meta"]["previous_stage"] == "proposal"
|
||||
assert payload["meta"]["changed"] is True
|
||||
assert pool.conn.opportunities["55555555-5555-5555-5555-555555555555"]["stage"] == "negotiation"
|
||||
|
||||
|
||||
def test_update_opportunity_rejects_cross_tenant_reference() -> None:
|
||||
pool = FakePool()
|
||||
client = _build_shared_app(pool, "tenant_beta")
|
||||
|
||||
response = client.patch(
|
||||
"/api/crm/opportunities/55555555-5555-5555-5555-555555555555",
|
||||
json={"stage": "negotiation"},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Opportunity '55555555-5555-5555-5555-555555555555' not found."
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
@@ -7,7 +8,10 @@ from typing import Any
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
os.environ.setdefault("VELOCITY_JWT_SECRET", "test-secret")
|
||||
|
||||
from backend.api.routes_crm import analytics_router, crm_router
|
||||
from backend.auth.dependencies import UserPrincipal, get_current_user
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
@@ -23,8 +27,36 @@ class FakeConn:
|
||||
normalized = query.strip()
|
||||
if "CREATE TABLE IF NOT EXISTS leads" in normalized or "CREATE TABLE IF NOT EXISTS chat_logs" in normalized:
|
||||
return "CREATE"
|
||||
if normalized.startswith("ALTER TABLE leads ADD COLUMN IF NOT EXISTS tenant_id"):
|
||||
for lead in self.leads.values():
|
||||
lead.setdefault("tenant_id", "tenant_velocity")
|
||||
return "ALTER TABLE"
|
||||
if normalized.startswith("ALTER TABLE chat_logs ADD COLUMN IF NOT EXISTS tenant_id"):
|
||||
for log in self.chat_logs.values():
|
||||
log.setdefault("tenant_id", "tenant_velocity")
|
||||
return "ALTER TABLE"
|
||||
if normalized.startswith("UPDATE leads") and "SET tenant_id = $1" in normalized:
|
||||
for lead in self.leads.values():
|
||||
if not lead.get("tenant_id"):
|
||||
lead["tenant_id"] = args[0]
|
||||
return "UPDATE"
|
||||
if normalized.startswith("UPDATE chat_logs") and "SET tenant_id = $1" in normalized:
|
||||
for log in self.chat_logs.values():
|
||||
if not log.get("tenant_id"):
|
||||
log["tenant_id"] = args[0]
|
||||
return "UPDATE"
|
||||
if normalized.startswith("ALTER TABLE leads ALTER COLUMN tenant_id SET DEFAULT"):
|
||||
return "ALTER TABLE"
|
||||
if normalized.startswith("ALTER TABLE chat_logs ALTER COLUMN tenant_id SET DEFAULT"):
|
||||
return "ALTER TABLE"
|
||||
if "CREATE INDEX IF NOT EXISTS" in normalized:
|
||||
return "CREATE INDEX"
|
||||
if normalized.startswith("DELETE FROM leads WHERE id = $1 AND tenant_id = $2"):
|
||||
existed = self.leads.get(args[0])
|
||||
if existed and existed["tenant_id"] == args[1]:
|
||||
self.leads.pop(args[0], None)
|
||||
return "DELETE 1"
|
||||
return "DELETE 0"
|
||||
if normalized.startswith("DELETE FROM leads WHERE id = $1"):
|
||||
existed = self.leads.pop(args[0], None)
|
||||
return "DELETE 1" if existed else "DELETE 0"
|
||||
@@ -33,18 +65,22 @@ class FakeConn:
|
||||
async def fetchrow(self, query: str, *args):
|
||||
normalized = query.strip()
|
||||
if "INSERT INTO leads" in normalized:
|
||||
has_tenant = "tenant_id" in normalized.split("(", 1)[1].split(")", 1)[0]
|
||||
tenant_id = args[1] if has_tenant else "tenant_velocity"
|
||||
base = 2 if has_tenant else 1
|
||||
row = {
|
||||
"id": args[0],
|
||||
"name": args[1],
|
||||
"email": args[2],
|
||||
"phone": args[3],
|
||||
"source": args[4],
|
||||
"notes": args[5],
|
||||
"qualification": args[6],
|
||||
"score": args[7],
|
||||
"kanban_status": args[8],
|
||||
"budget": args[9],
|
||||
"unit_interest": args[10],
|
||||
"tenant_id": tenant_id,
|
||||
"name": args[base],
|
||||
"email": args[base + 1],
|
||||
"phone": args[base + 2],
|
||||
"source": args[base + 3],
|
||||
"notes": args[base + 4],
|
||||
"qualification": args[base + 5],
|
||||
"score": args[base + 6],
|
||||
"kanban_status": args[base + 7],
|
||||
"budget": args[base + 8],
|
||||
"unit_interest": args[base + 9],
|
||||
"metadata": {},
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
@@ -53,7 +89,7 @@ class FakeConn:
|
||||
return row
|
||||
if normalized.startswith("UPDATE leads") and "SET kanban_status" in normalized:
|
||||
lead = self.leads.get(args[0])
|
||||
if not lead:
|
||||
if not lead or lead["tenant_id"] != args[2]:
|
||||
return None
|
||||
lead["kanban_status"] = args[1]
|
||||
lead["updated_at"] = _now()
|
||||
@@ -66,7 +102,7 @@ class FakeConn:
|
||||
return lead
|
||||
if normalized.startswith("UPDATE leads") and "RETURNING" in normalized:
|
||||
lead = self.leads.get(args[0])
|
||||
if not lead:
|
||||
if not lead or lead["tenant_id"] != args[12]:
|
||||
return None
|
||||
lead.update(
|
||||
{
|
||||
@@ -84,38 +120,47 @@ class FakeConn:
|
||||
}
|
||||
)
|
||||
return lead
|
||||
if normalized.startswith("SELECT id FROM leads WHERE id = $1"):
|
||||
if normalized.startswith("SELECT id FROM leads WHERE id = $1 AND tenant_id = $2"):
|
||||
lead = self.leads.get(args[0])
|
||||
return {"id": lead["id"]} if lead else None
|
||||
return {"id": lead["id"]} if lead and lead["tenant_id"] == args[1] else None
|
||||
if "INSERT INTO chat_logs" in normalized:
|
||||
has_tenant = "tenant_id" in normalized.split("(", 1)[1].split(")", 1)[0]
|
||||
tenant_id = args[1] if has_tenant else "tenant_velocity"
|
||||
base = 2 if has_tenant else 1
|
||||
row = {
|
||||
"id": args[0],
|
||||
"lead_id": args[1],
|
||||
"sender": args[2],
|
||||
"channel": args[3],
|
||||
"content": args[4],
|
||||
"tenant_id": tenant_id,
|
||||
"lead_id": args[base],
|
||||
"sender": args[base + 1],
|
||||
"channel": args[base + 2],
|
||||
"content": args[base + 3],
|
||||
"metadata": {},
|
||||
"created_at": _now(),
|
||||
}
|
||||
self.chat_logs[row["id"]] = row
|
||||
return row
|
||||
if "FROM leads" in normalized and "WHERE id = $1 AND tenant_id = $2" in normalized:
|
||||
lead = self.leads.get(args[0])
|
||||
return lead if lead and lead["tenant_id"] == args[1] else None
|
||||
raise AssertionError(f"Unexpected fetchrow query: {query}")
|
||||
|
||||
async def fetch(self, query: str, *args):
|
||||
normalized = query.strip()
|
||||
if "FROM leads" in normalized and "GROUP BY source" not in normalized and "GROUP BY qualification" not in normalized:
|
||||
rows = list(self.leads.values())
|
||||
if "WHERE kanban_status = $1" in normalized:
|
||||
rows = [row for row in rows if row["kanban_status"] == args[0]]
|
||||
rows = [row for row in self.leads.values() if row["tenant_id"] == args[0]]
|
||||
if "WHERE tenant_id = $1 AND kanban_status = $2" in normalized:
|
||||
rows = [row for row in rows if row["kanban_status"] == args[1]]
|
||||
return rows
|
||||
if "FROM chat_logs" in normalized:
|
||||
rows = list(self.chat_logs.values())
|
||||
if "WHERE lead_id = $1" in normalized:
|
||||
rows = [row for row in rows if row["lead_id"] == args[0]]
|
||||
rows = [row for row in self.chat_logs.values() if row["tenant_id"] == args[0]]
|
||||
if "WHERE tenant_id = $1 AND lead_id = $2" in normalized:
|
||||
rows = [row for row in rows if row["lead_id"] == args[1]]
|
||||
return rows
|
||||
if "GROUP BY source" in normalized:
|
||||
grouped: dict[str, dict[str, Any]] = {}
|
||||
for lead in self.leads.values():
|
||||
if lead["tenant_id"] != args[0]:
|
||||
continue
|
||||
slot = grouped.setdefault(lead["source"], {"source": lead["source"], "lead_count": 0, "avg_score": 0.0})
|
||||
slot["lead_count"] += 1
|
||||
slot["avg_score"] += float(lead["score"])
|
||||
@@ -125,6 +170,8 @@ class FakeConn:
|
||||
if "GROUP BY qualification" in normalized:
|
||||
grouped: dict[str, dict[str, Any]] = {}
|
||||
for lead in self.leads.values():
|
||||
if lead["tenant_id"] != args[0]:
|
||||
continue
|
||||
slot = grouped.setdefault(lead["qualification"], {"qualification": lead["qualification"], "lead_count": 0})
|
||||
slot["lead_count"] += 1
|
||||
return list(grouped.values())
|
||||
@@ -140,15 +187,34 @@ class FakePool:
|
||||
yield self.conn
|
||||
|
||||
|
||||
def _build_client() -> tuple[TestClient, FakePool]:
|
||||
def _build_client(
|
||||
*,
|
||||
authenticated: bool = True,
|
||||
tenant_id: str = "tenant_velocity",
|
||||
) -> tuple[TestClient, FakePool]:
|
||||
app = FastAPI()
|
||||
pool = FakePool()
|
||||
app.state.db_pool = pool
|
||||
app.include_router(crm_router, prefix="/api")
|
||||
app.include_router(analytics_router, prefix="/api/analytics")
|
||||
if authenticated:
|
||||
app.dependency_overrides[get_current_user] = lambda: UserPrincipal("user-1", "ADMIN", tenant_id)
|
||||
return TestClient(app), pool
|
||||
|
||||
|
||||
def _build_shared_app(pool: FakePool, current_user: dict[str, str]) -> TestClient:
|
||||
app = FastAPI()
|
||||
app.state.db_pool = pool
|
||||
app.include_router(crm_router, prefix="/api")
|
||||
app.include_router(analytics_router, prefix="/api/analytics")
|
||||
app.dependency_overrides[get_current_user] = lambda: UserPrincipal(
|
||||
"user-1",
|
||||
current_user["role"],
|
||||
current_user["tenant_id"],
|
||||
)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_crm_crud_and_analytics_flow() -> None:
|
||||
client, _pool = _build_client()
|
||||
|
||||
@@ -213,3 +279,42 @@ def test_lead_demographics_groups_by_source_and_qualification() -> None:
|
||||
payload = response.json()["data"]
|
||||
assert len(payload["by_source"]) == 2
|
||||
assert any(row["qualification"] == "POTENTIAL" for row in payload["by_qualification"])
|
||||
|
||||
|
||||
def test_crm_routes_require_authentication() -> None:
|
||||
client, _pool = _build_client(authenticated=False)
|
||||
|
||||
response = client.get("/api/leads")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json()["detail"] == "Missing or malformed Authorization header."
|
||||
|
||||
|
||||
def test_crm_routes_are_scoped_to_authenticated_tenant() -> None:
|
||||
pool = FakePool()
|
||||
tenant_a = {"role": "ADMIN", "tenant_id": "tenant_alpha"}
|
||||
client_a = _build_shared_app(pool, tenant_a)
|
||||
|
||||
create_response = client_a.post(
|
||||
"/api/leads",
|
||||
json={"name": "Tenant Alpha Lead", "source": "website", "score": 88},
|
||||
)
|
||||
assert create_response.status_code == 201
|
||||
lead_id = create_response.json()["data"]["id"]
|
||||
|
||||
tenant_b = {"role": "ADMIN", "tenant_id": "tenant_beta"}
|
||||
client_b = _build_shared_app(pool, tenant_b)
|
||||
|
||||
list_response = client_b.get("/api/leads")
|
||||
assert list_response.status_code == 200
|
||||
assert list_response.json()["meta"]["count"] == 0
|
||||
|
||||
get_response = client_b.get(f"/api/leads/{lead_id}")
|
||||
assert get_response.status_code == 404
|
||||
|
||||
delete_response = client_b.delete(f"/api/leads/{lead_id}")
|
||||
assert delete_response.status_code == 404
|
||||
|
||||
own_list_response = client_a.get("/api/leads")
|
||||
assert own_list_response.status_code == 200
|
||||
assert own_list_response.json()["meta"]["count"] == 1
|
||||
|
||||
30
backend/tests/test_dream_weaver_gateway_auth.py
Normal file
30
backend/tests/test_dream_weaver_gateway_auth.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from comfy_engine.scripts.gateway_auth import (
|
||||
extract_gateway_api_key,
|
||||
is_gateway_request_authorized,
|
||||
load_gateway_api_key,
|
||||
)
|
||||
|
||||
|
||||
def test_load_gateway_api_key_prefers_explicit_gateway_env() -> None:
|
||||
env = {
|
||||
"DREAM_WEAVER_API_KEY": "fallback-key",
|
||||
"DREAM_WEAVER_GATEWAY_API_KEY": "primary-key",
|
||||
}
|
||||
assert load_gateway_api_key(env) == "primary-key"
|
||||
|
||||
|
||||
def test_extract_gateway_api_key_supports_dedicated_headers() -> None:
|
||||
assert extract_gateway_api_key({"x-dream-weaver-api-key": "dw-key"}) == "dw-key"
|
||||
assert extract_gateway_api_key({"x-api-key": "legacy-key"}) == "legacy-key"
|
||||
|
||||
|
||||
def test_extract_gateway_api_key_supports_bearer_authorization() -> None:
|
||||
assert extract_gateway_api_key({"authorization": "Bearer shared-key"}) == "shared-key"
|
||||
|
||||
|
||||
def test_gateway_auth_allows_open_gateways_and_blocks_wrong_keys() -> None:
|
||||
assert is_gateway_request_authorized({}, None) is True
|
||||
assert is_gateway_request_authorized({"x-dream-weaver-api-key": "correct"}, "correct") is True
|
||||
assert is_gateway_request_authorized({"authorization": "Bearer wrong"}, "correct") is False
|
||||
238
backend/tests/test_legacy_crm_canonical_bridge.py
Normal file
238
backend/tests/test_legacy_crm_canonical_bridge.py
Normal file
@@ -0,0 +1,238 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
os.environ.setdefault("VELOCITY_JWT_SECRET", "test-secret")
|
||||
|
||||
from backend.api import routes_crm
|
||||
from backend.auth.dependencies import UserPrincipal, get_current_user
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class FakeConn:
|
||||
def __init__(self) -> None:
|
||||
self.leads: dict[str, dict[str, Any]] = {
|
||||
"legacy-1": {
|
||||
"id": "legacy-1",
|
||||
"tenant_id": "tenant_alpha",
|
||||
"name": "Legacy Duplicate",
|
||||
"email": "legacy.duplicate@example.com",
|
||||
"phone": "+971500000001",
|
||||
"source": "website",
|
||||
"notes": "Old legacy note",
|
||||
"qualification": "HOT",
|
||||
"score": 82,
|
||||
"kanban_status": "Qualifying",
|
||||
"budget": "AED 5M",
|
||||
"unit_interest": "Legacy Tower",
|
||||
"metadata": {},
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
},
|
||||
"legacy-2": {
|
||||
"id": "legacy-2",
|
||||
"tenant_id": "tenant_alpha",
|
||||
"name": "Legacy Only",
|
||||
"email": "legacy.only@example.com",
|
||||
"phone": "+971500000002",
|
||||
"source": "walkin",
|
||||
"notes": "Pure legacy lead",
|
||||
"qualification": "POTENTIAL",
|
||||
"score": 74,
|
||||
"kanban_status": "Negotiation",
|
||||
"budget": "AED 7M",
|
||||
"unit_interest": "Legacy Residence",
|
||||
"metadata": {},
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
},
|
||||
}
|
||||
self.chat_logs: dict[str, dict[str, Any]] = {}
|
||||
|
||||
async def execute(self, query: str, *args):
|
||||
normalized = " ".join(query.strip().split())
|
||||
if "CREATE TABLE IF NOT EXISTS leads" in normalized or "CREATE TABLE IF NOT EXISTS chat_logs" in normalized:
|
||||
return "CREATE"
|
||||
if normalized.startswith("ALTER TABLE leads ADD COLUMN IF NOT EXISTS tenant_id"):
|
||||
return "ALTER TABLE"
|
||||
if normalized.startswith("ALTER TABLE chat_logs ADD COLUMN IF NOT EXISTS tenant_id"):
|
||||
return "ALTER TABLE"
|
||||
if normalized.startswith("UPDATE leads SET tenant_id = $1 WHERE tenant_id IS NULL OR tenant_id = ''"):
|
||||
return "UPDATE"
|
||||
if normalized.startswith("UPDATE chat_logs SET tenant_id = $1 WHERE tenant_id IS NULL OR tenant_id = ''"):
|
||||
return "UPDATE"
|
||||
if normalized.startswith("ALTER TABLE leads ALTER COLUMN tenant_id SET DEFAULT"):
|
||||
return "ALTER TABLE"
|
||||
if normalized.startswith("ALTER TABLE chat_logs ALTER COLUMN tenant_id SET DEFAULT"):
|
||||
return "ALTER TABLE"
|
||||
if "CREATE INDEX IF NOT EXISTS" in normalized:
|
||||
return "CREATE INDEX"
|
||||
raise AssertionError(f"Unexpected execute query: {query}")
|
||||
|
||||
async def fetch(self, query: str, *args):
|
||||
normalized = " ".join(query.strip().split())
|
||||
if "FROM leads" in normalized:
|
||||
rows = [row for row in self.leads.values() if row["tenant_id"] == args[0]]
|
||||
return rows
|
||||
if "FROM chat_logs" in normalized:
|
||||
rows = [row for row in self.chat_logs.values() if row["tenant_id"] == args[0]]
|
||||
if len(args) >= 2:
|
||||
rows = [row for row in rows if row["lead_id"] == args[1]]
|
||||
return rows
|
||||
raise AssertionError(f"Unexpected fetch query: {query}")
|
||||
|
||||
async def fetchrow(self, query: str, *args):
|
||||
normalized = " ".join(query.strip().split())
|
||||
if "FROM leads" in normalized and "WHERE id = $1 AND tenant_id = $2" in normalized:
|
||||
row = self.leads.get(args[0])
|
||||
return row if row and row["tenant_id"] == args[1] else None
|
||||
raise AssertionError(f"Unexpected fetchrow query: {query}")
|
||||
|
||||
|
||||
class FakePool:
|
||||
def __init__(self) -> None:
|
||||
self.conn = FakeConn()
|
||||
|
||||
@asynccontextmanager
|
||||
async def acquire(self):
|
||||
yield self.conn
|
||||
|
||||
|
||||
def _build_client() -> TestClient:
|
||||
app = FastAPI()
|
||||
app.state.db_pool = FakePool()
|
||||
app.include_router(routes_crm.crm_router, prefix="/api")
|
||||
app.dependency_overrides[get_current_user] = lambda: UserPrincipal(
|
||||
"user-1",
|
||||
"ADMIN",
|
||||
"tenant_alpha",
|
||||
)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_list_leads_merges_canonical_and_legacy_without_duplicate_shadow(monkeypatch) -> None:
|
||||
client = _build_client()
|
||||
|
||||
async def fake_fetch_canonical_leads(conn: Any, tenant_id: str, search: str | None = None) -> list[dict[str, Any]]:
|
||||
assert tenant_id == "tenant_alpha"
|
||||
return [
|
||||
{
|
||||
"id": "legacy-1",
|
||||
"name": "Canonical Preferred",
|
||||
"email": "canonical@example.com",
|
||||
"phone": "+971500009999",
|
||||
"source": "website",
|
||||
"notes": "Canonical note",
|
||||
"qualification": "WHALE",
|
||||
"score": 96,
|
||||
"kanban_status": "Negotiation",
|
||||
"stage": "negotiation",
|
||||
"budget": "AED 18M",
|
||||
"unit_interest": "Sky Deck Penthouse",
|
||||
"metadata": {
|
||||
"legacy_lead_id": "legacy-1",
|
||||
"canonical_lead_id": "canon-1",
|
||||
"canonical_person_id": "person-1",
|
||||
},
|
||||
"created_at": "2026-04-22T10:00:00+00:00",
|
||||
"updated_at": "2026-04-22T11:00:00+00:00",
|
||||
}
|
||||
]
|
||||
|
||||
monkeypatch.setattr(routes_crm, "_fetch_canonical_leads", fake_fetch_canonical_leads)
|
||||
|
||||
response = client.get("/api/leads")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()["data"]
|
||||
assert len(payload) == 2
|
||||
assert payload[0]["name"] == "Canonical Preferred"
|
||||
assert payload[1]["id"] == "legacy-2"
|
||||
|
||||
|
||||
def test_get_lead_resolves_canonical_record_by_canonical_id(monkeypatch) -> None:
|
||||
client = _build_client()
|
||||
|
||||
async def fake_fetch_canonical_leads(conn: Any, tenant_id: str, search: str | None = None) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"id": "legacy-1",
|
||||
"name": "Canonical Preferred",
|
||||
"email": "canonical@example.com",
|
||||
"phone": "+971500009999",
|
||||
"source": "website",
|
||||
"notes": "Canonical note",
|
||||
"qualification": "WHALE",
|
||||
"score": 96,
|
||||
"kanban_status": "Negotiation",
|
||||
"stage": "negotiation",
|
||||
"budget": "AED 18M",
|
||||
"unit_interest": "Sky Deck Penthouse",
|
||||
"metadata": {
|
||||
"legacy_lead_id": "legacy-1",
|
||||
"canonical_lead_id": "canon-1",
|
||||
"canonical_person_id": "person-1",
|
||||
},
|
||||
"created_at": "2026-04-22T10:00:00+00:00",
|
||||
"updated_at": "2026-04-22T11:00:00+00:00",
|
||||
}
|
||||
]
|
||||
|
||||
monkeypatch.setattr(routes_crm, "_fetch_canonical_leads", fake_fetch_canonical_leads)
|
||||
|
||||
response = client.get("/api/leads/canon-1")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["data"]["name"] == "Canonical Preferred"
|
||||
|
||||
|
||||
def test_chat_logs_fall_back_to_canonical_interactions(monkeypatch) -> None:
|
||||
client = _build_client()
|
||||
|
||||
async def fake_fetch_canonical_chat_logs(conn: Any, tenant_id: str, lead_id: str, channel: str | None = None) -> list[dict[str, Any]]:
|
||||
assert tenant_id == "tenant_alpha"
|
||||
assert lead_id == "canon-1"
|
||||
return [
|
||||
{
|
||||
"id": "interaction-1",
|
||||
"lead_id": "canon-1",
|
||||
"sender": "oracle",
|
||||
"channel": "whatsapp",
|
||||
"content": "Canonical interaction summary",
|
||||
"metadata": {"source_of_truth": "canonical_crm"},
|
||||
"created_at": "2026-04-22T12:00:00+00:00",
|
||||
}
|
||||
]
|
||||
|
||||
monkeypatch.setattr(routes_crm, "_fetch_canonical_chat_logs", fake_fetch_canonical_chat_logs)
|
||||
|
||||
response = client.get("/api/chat-logs", params={"lead_id": "canon-1"})
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()["data"]
|
||||
assert len(payload) == 1
|
||||
assert payload[0]["content"] == "Canonical interaction summary"
|
||||
|
||||
|
||||
def test_list_leads_falls_back_to_legacy_when_canonical_bridge_unavailable(monkeypatch) -> None:
|
||||
client = _build_client()
|
||||
|
||||
async def failing_fetch_canonical_leads(conn: Any, tenant_id: str, search: str | None = None) -> list[dict[str, Any]]:
|
||||
raise RuntimeError("canonical tables unavailable")
|
||||
|
||||
monkeypatch.setattr(routes_crm, "_fetch_canonical_leads", failing_fetch_canonical_leads)
|
||||
|
||||
response = client.get("/api/leads")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()["data"]
|
||||
assert [lead["id"] for lead in payload] == ["legacy-1", "legacy-2"]
|
||||
243
backend/tests/test_legacy_crm_write_bridge.py
Normal file
243
backend/tests/test_legacy_crm_write_bridge.py
Normal file
@@ -0,0 +1,243 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
os.environ.setdefault("VELOCITY_JWT_SECRET", "test-secret")
|
||||
|
||||
from backend.api.routes_crm import crm_router
|
||||
from backend.auth.dependencies import UserPrincipal, get_current_user
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class FakeConn:
|
||||
def __init__(self) -> None:
|
||||
self.leads: dict[str, dict[str, Any]] = {}
|
||||
self.chat_logs: dict[str, dict[str, Any]] = {}
|
||||
|
||||
async def execute(self, query: str, *args):
|
||||
normalized = " ".join(query.strip().split())
|
||||
if "CREATE TABLE IF NOT EXISTS leads" in normalized or "CREATE TABLE IF NOT EXISTS chat_logs" in normalized:
|
||||
return "CREATE"
|
||||
if normalized.startswith("ALTER TABLE leads ADD COLUMN IF NOT EXISTS tenant_id"):
|
||||
return "ALTER TABLE"
|
||||
if normalized.startswith("ALTER TABLE chat_logs ADD COLUMN IF NOT EXISTS tenant_id"):
|
||||
return "ALTER TABLE"
|
||||
if normalized.startswith("UPDATE leads SET tenant_id = $1 WHERE tenant_id IS NULL OR tenant_id = ''"):
|
||||
return "UPDATE"
|
||||
if normalized.startswith("UPDATE chat_logs SET tenant_id = $1 WHERE tenant_id IS NULL OR tenant_id = ''"):
|
||||
return "UPDATE"
|
||||
if normalized.startswith("ALTER TABLE leads ALTER COLUMN tenant_id SET DEFAULT"):
|
||||
return "ALTER TABLE"
|
||||
if normalized.startswith("ALTER TABLE chat_logs ALTER COLUMN tenant_id SET DEFAULT"):
|
||||
return "ALTER TABLE"
|
||||
if "CREATE INDEX IF NOT EXISTS" in normalized:
|
||||
return "CREATE INDEX"
|
||||
if normalized.startswith("DELETE FROM leads WHERE id = $1 AND tenant_id = $2"):
|
||||
existed = self.leads.get(args[0])
|
||||
if existed and existed["tenant_id"] == args[1]:
|
||||
self.leads.pop(args[0], None)
|
||||
return "DELETE 1"
|
||||
return "DELETE 0"
|
||||
raise AssertionError(f"Unexpected execute query: {query}")
|
||||
|
||||
async def fetchrow(self, query: str, *args):
|
||||
normalized = " ".join(query.strip().split())
|
||||
if "INSERT INTO leads" in normalized:
|
||||
row = {
|
||||
"id": args[0],
|
||||
"tenant_id": args[1],
|
||||
"name": args[2],
|
||||
"email": args[3],
|
||||
"phone": args[4],
|
||||
"source": args[5],
|
||||
"notes": args[6],
|
||||
"qualification": args[7],
|
||||
"score": args[8],
|
||||
"kanban_status": args[9],
|
||||
"budget": args[10],
|
||||
"unit_interest": args[11],
|
||||
"metadata": {},
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
}
|
||||
self.leads[row["id"]] = row
|
||||
return row
|
||||
if normalized.startswith("UPDATE leads") and "RETURNING" in normalized:
|
||||
row = self.leads.get(args[0])
|
||||
tenant_arg = args[-1]
|
||||
if not row or row["tenant_id"] != tenant_arg:
|
||||
return None
|
||||
if len(args) == 13:
|
||||
row.update(
|
||||
{
|
||||
"name": args[1],
|
||||
"email": args[2],
|
||||
"phone": args[3],
|
||||
"source": args[4],
|
||||
"notes": args[5],
|
||||
"qualification": args[6],
|
||||
"score": args[7],
|
||||
"kanban_status": args[8],
|
||||
"budget": args[9],
|
||||
"unit_interest": args[10],
|
||||
"updated_at": _now(),
|
||||
}
|
||||
)
|
||||
else:
|
||||
row.update(
|
||||
{
|
||||
"kanban_status": args[1],
|
||||
"qualification": "HOT" if row["score"] >= 45 else row["qualification"],
|
||||
"updated_at": _now(),
|
||||
}
|
||||
)
|
||||
return row
|
||||
if normalized.startswith("SELECT id, name, email, phone, source, notes, qualification, score, kanban_status, budget, unit_interest, metadata, created_at, updated_at FROM leads WHERE id = $1 AND tenant_id = $2"):
|
||||
row = self.leads.get(args[0])
|
||||
return row if row and row["tenant_id"] == args[1] else None
|
||||
if normalized.startswith("INSERT INTO chat_logs"):
|
||||
row = {
|
||||
"id": args[0],
|
||||
"tenant_id": args[1],
|
||||
"lead_id": args[2],
|
||||
"sender": args[3],
|
||||
"channel": args[4],
|
||||
"content": args[5],
|
||||
"metadata": {},
|
||||
"created_at": _now(),
|
||||
}
|
||||
self.chat_logs[row["id"]] = row
|
||||
return row
|
||||
raise AssertionError(f"Unexpected fetchrow query: {query}")
|
||||
|
||||
|
||||
class FakePool:
|
||||
def __init__(self) -> None:
|
||||
self.conn = FakeConn()
|
||||
|
||||
@asynccontextmanager
|
||||
async def acquire(self):
|
||||
yield self.conn
|
||||
|
||||
|
||||
def _build_client() -> tuple[TestClient, FakePool]:
|
||||
app = FastAPI()
|
||||
pool = FakePool()
|
||||
app.state.db_pool = pool
|
||||
app.include_router(crm_router, prefix="/api")
|
||||
app.dependency_overrides[get_current_user] = lambda: UserPrincipal("user-1", "ADMIN", "tenant_alpha")
|
||||
return TestClient(app), pool
|
||||
|
||||
|
||||
def test_create_lead_triggers_canonical_write_bridge(monkeypatch) -> None:
|
||||
client, _pool = _build_client()
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
async def fake_sync(request, conn, user, legacy_lead):
|
||||
calls.append({"lead_id": legacy_lead["id"], "name": legacy_lead["name"], "tenant_id": user.tenant_id})
|
||||
return {"person_id": "person-1", "lead_id": "canon-1"}
|
||||
|
||||
monkeypatch.setattr("backend.api.routes_crm._sync_canonical_lead_bridge", fake_sync)
|
||||
|
||||
response = client.post("/api/leads", json={"name": "Amina Rahman", "source": "website", "score": 88})
|
||||
|
||||
assert response.status_code == 201
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["name"] == "Amina Rahman"
|
||||
assert calls[0]["tenant_id"] == "tenant_alpha"
|
||||
|
||||
|
||||
def test_update_lead_triggers_canonical_write_bridge(monkeypatch) -> None:
|
||||
client, _pool = _build_client()
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
async def fake_sync(request, conn, user, legacy_lead):
|
||||
calls.append({"lead_id": legacy_lead["id"], "status": legacy_lead["kanban_status"]})
|
||||
return {"person_id": "person-1", "lead_id": "canon-1"}
|
||||
|
||||
monkeypatch.setattr("backend.api.routes_crm._sync_canonical_lead_bridge", fake_sync)
|
||||
|
||||
create_response = client.post("/api/leads", json={"name": "Lead One", "source": "website", "score": 60})
|
||||
lead_id = create_response.json()["data"]["id"]
|
||||
calls.clear()
|
||||
|
||||
update_response = client.put(
|
||||
f"/api/leads/{lead_id}",
|
||||
json={"name": "Lead One Updated", "source": "website", "score": 75, "kanban_status": "negotiation"},
|
||||
)
|
||||
|
||||
assert update_response.status_code == 200
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["lead_id"] == lead_id
|
||||
assert calls[0]["status"] == "Negotiation"
|
||||
|
||||
|
||||
def test_create_chat_log_triggers_canonical_chat_bridge(monkeypatch) -> None:
|
||||
client, _pool = _build_client()
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
async def fake_lead_sync(request, conn, user, legacy_lead):
|
||||
return {"person_id": "person-1", "lead_id": "canon-1"}
|
||||
|
||||
async def fake_chat_sync(request, conn, user, legacy_chat_log, legacy_lead):
|
||||
calls.append(
|
||||
{
|
||||
"chat_log_id": legacy_chat_log["id"],
|
||||
"lead_id": legacy_chat_log["lead_id"],
|
||||
"content": legacy_chat_log["content"],
|
||||
"lead_name": legacy_lead["name"],
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr("backend.api.routes_crm._sync_canonical_lead_bridge", fake_lead_sync)
|
||||
monkeypatch.setattr("backend.api.routes_crm._sync_canonical_chat_log_bridge", fake_chat_sync)
|
||||
|
||||
create_response = client.post("/api/leads", json={"name": "Lead One", "source": "website", "score": 60})
|
||||
lead_id = create_response.json()["data"]["id"]
|
||||
|
||||
response = client.post(
|
||||
"/api/chat-logs",
|
||||
json={"lead_id": lead_id, "sender": "oracle", "channel": "whatsapp", "content": "Follow up tonight"},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["lead_id"] == lead_id
|
||||
assert calls[0]["content"] == "Follow up tonight"
|
||||
|
||||
|
||||
def test_move_and_delete_trigger_canonical_write_bridges(monkeypatch) -> None:
|
||||
client, _pool = _build_client()
|
||||
move_calls: list[dict[str, Any]] = []
|
||||
delete_calls: list[str] = []
|
||||
|
||||
async def fake_sync(request, conn, user, legacy_lead):
|
||||
move_calls.append({"lead_id": legacy_lead["id"], "status": legacy_lead["kanban_status"]})
|
||||
return {"person_id": "person-1", "lead_id": "canon-1"}
|
||||
|
||||
async def fake_delete(request, conn, user, legacy_lead_id):
|
||||
delete_calls.append(legacy_lead_id)
|
||||
|
||||
monkeypatch.setattr("backend.api.routes_crm._sync_canonical_lead_bridge", fake_sync)
|
||||
monkeypatch.setattr("backend.api.routes_crm._delete_canonical_lead_bridge", fake_delete)
|
||||
|
||||
create_response = client.post("/api/leads", json={"name": "Lead One", "source": "website", "score": 60})
|
||||
lead_id = create_response.json()["data"]["id"]
|
||||
move_calls.clear()
|
||||
|
||||
move_response = client.put("/api/kanban/move", json={"lead_id": lead_id, "target_status": "site_visit"})
|
||||
delete_response = client.delete(f"/api/leads/{lead_id}")
|
||||
|
||||
assert move_response.status_code == 200
|
||||
assert delete_response.status_code == 200
|
||||
assert move_calls == [{"lead_id": lead_id, "status": "Site Visit"}]
|
||||
assert delete_calls == [lead_id]
|
||||
40
backend/tests/test_migrations_and_observability.py
Normal file
40
backend/tests/test_migrations_and_observability.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
os.environ.setdefault("VELOCITY_JWT_SECRET", "test-secret")
|
||||
|
||||
from backend.migrations.runner import discover_migrations
|
||||
from backend.observability import RequestObservabilityMiddleware
|
||||
|
||||
|
||||
def test_migration_discovery_is_ordered_and_checksummed() -> None:
|
||||
migrations = discover_migrations(Path("backend/migrations/versions"))
|
||||
|
||||
assert migrations
|
||||
assert migrations == sorted(migrations, key=lambda migration: migration.version)
|
||||
assert all(len(migration.checksum) == 64 for migration in migrations)
|
||||
assert len({migration.version for migration in migrations}) == len(migrations)
|
||||
|
||||
|
||||
def test_observability_middleware_adds_request_headers_and_snapshot() -> None:
|
||||
app = FastAPI()
|
||||
app.add_middleware(RequestObservabilityMiddleware)
|
||||
|
||||
@app.get("/ping")
|
||||
async def ping() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/ping", headers={"X-Request-ID": "req-test"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["X-Request-ID"] == "req-test"
|
||||
assert "X-Response-Time-Ms" in response.headers
|
||||
assert app.state.request_metrics[-1].request_id == "req-test"
|
||||
assert app.state.request_metrics[-1].path == "/ping"
|
||||
|
||||
470
backend/tests/test_surface_route_tenant_scoping.py
Normal file
470
backend/tests/test_surface_route_tenant_scoping.py
Normal file
@@ -0,0 +1,470 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
os.environ.setdefault("VELOCITY_JWT_SECRET", "test-secret")
|
||||
|
||||
from backend.api.routes_inventory import router as inventory_router
|
||||
from backend.api.routes_mobile_edge import router as mobile_edge_router
|
||||
from backend.auth.dependencies import UserPrincipal, get_current_user
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class FakeSurfaceConn:
|
||||
def __init__(self) -> None:
|
||||
self.events: dict[str, dict[str, Any]] = {}
|
||||
self.calendar_events: dict[str, dict[str, Any]] = {}
|
||||
self.properties: dict[str, dict[str, Any]] = {}
|
||||
self.import_batches: dict[str, dict[str, Any]] = {}
|
||||
|
||||
async def fetchrow(self, query: str, *args):
|
||||
normalized = " ".join(query.strip().split())
|
||||
|
||||
if "INSERT INTO edge_communication_events" in normalized:
|
||||
event_id = str(uuid.uuid4())
|
||||
row = {
|
||||
"event_id": event_id,
|
||||
"tenant_id": args[0],
|
||||
"lead_id": args[1],
|
||||
"channel": args[2],
|
||||
"direction": args[3],
|
||||
"provider": args[4],
|
||||
"capture_mode": args[5],
|
||||
"consent_state": args[6],
|
||||
"duration_seconds": args[7],
|
||||
"summary": args[8],
|
||||
"raw_reference": args[9],
|
||||
"recording_ref": args[10],
|
||||
"provider_metadata": args[11],
|
||||
"timestamp": _now(),
|
||||
"created_at": _now(),
|
||||
}
|
||||
self.events[event_id] = row
|
||||
return {"event_id": event_id, "created_at": row["created_at"]}
|
||||
|
||||
if "INSERT INTO user_calendar_events" in normalized:
|
||||
calendar_event_id = str(uuid.uuid4())
|
||||
row = {
|
||||
"calendar_event_id": calendar_event_id,
|
||||
"tenant_id": args[0],
|
||||
"owner_user_id": args[1],
|
||||
"lead_id": args[2],
|
||||
"source_event_id": args[3],
|
||||
"title": args[4],
|
||||
"description": args[5],
|
||||
"start_at": args[6],
|
||||
"end_at": args[7],
|
||||
"all_day": args[8],
|
||||
"status": args[9],
|
||||
"reminder_minutes": args[10],
|
||||
"created_by": args[11],
|
||||
"location": args[12],
|
||||
"metadata": args[13],
|
||||
"created_at": _now(),
|
||||
}
|
||||
self.calendar_events[calendar_event_id] = row
|
||||
return row
|
||||
|
||||
if "INSERT INTO inventory_import_batches" in normalized:
|
||||
batch_id = str(uuid.uuid4())
|
||||
row = {
|
||||
"batch_id": batch_id,
|
||||
"tenant_id": args[0],
|
||||
"source_type": args[1],
|
||||
"submitted_by": args[2],
|
||||
"total_rows": args[3],
|
||||
"source_file_ref": args[4],
|
||||
"accepted_rows": 0,
|
||||
"rejected_rows": 0,
|
||||
"status": "pending",
|
||||
"created_at": _now(),
|
||||
"completed_at": None,
|
||||
}
|
||||
self.import_batches[batch_id] = row
|
||||
return {
|
||||
"batch_id": batch_id,
|
||||
"status": row["status"],
|
||||
"created_at": row["created_at"],
|
||||
}
|
||||
|
||||
if "INSERT INTO inventory_properties" in normalized:
|
||||
property_id = str(uuid.uuid4())
|
||||
row = {
|
||||
"property_id": property_id,
|
||||
"tenant_id": args[0],
|
||||
"batch_id": args[1],
|
||||
"source_id": args[2],
|
||||
"project_name": args[3],
|
||||
"developer_name": args[4],
|
||||
"location": args[5],
|
||||
"property_type": args[6],
|
||||
"price_bands": args[7],
|
||||
"unit_mix": args[8],
|
||||
"amenities": args[9],
|
||||
"status": args[10],
|
||||
"validation_state": args[11],
|
||||
"ingested_at": None,
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
}
|
||||
self.properties[property_id] = row
|
||||
return {"property_id": property_id, "created_at": row["created_at"]}
|
||||
|
||||
if normalized.startswith("SELECT * FROM inventory_import_batches WHERE batch_id=$1 AND tenant_id=$2"):
|
||||
row = self.import_batches.get(args[0])
|
||||
return row if row and row["tenant_id"] == args[1] else None
|
||||
|
||||
if normalized.startswith("SELECT * FROM inventory_properties WHERE property_id=$1 AND tenant_id=$2"):
|
||||
row = self.properties.get(args[0])
|
||||
return row if row and row["tenant_id"] == args[1] else None
|
||||
|
||||
raise AssertionError(f"Unexpected fetchrow query: {query}")
|
||||
|
||||
async def fetch(self, query: str, *args):
|
||||
normalized = " ".join(query.strip().split())
|
||||
|
||||
if "FROM edge_communication_events" in normalized:
|
||||
tenant_id, lead_id, limit, offset = args
|
||||
rows = [
|
||||
{
|
||||
"event_id": row["event_id"],
|
||||
"lead_id": row["lead_id"],
|
||||
"channel": row["channel"],
|
||||
"direction": row["direction"],
|
||||
"provider": row["provider"],
|
||||
"capture_mode": row["capture_mode"],
|
||||
"consent_state": row["consent_state"],
|
||||
"timestamp": row["timestamp"].isoformat(),
|
||||
"duration_seconds": row["duration_seconds"],
|
||||
"summary": row["summary"],
|
||||
"raw_reference": row["raw_reference"],
|
||||
"recording_ref": row["recording_ref"],
|
||||
"provider_metadata": row["provider_metadata"],
|
||||
"created_at": row["created_at"].isoformat(),
|
||||
}
|
||||
for row in self.events.values()
|
||||
if row["tenant_id"] == tenant_id and row["lead_id"] == lead_id
|
||||
]
|
||||
rows.sort(key=lambda item: item["timestamp"], reverse=True)
|
||||
return rows[offset : offset + limit]
|
||||
|
||||
if "FROM user_calendar_events" in normalized:
|
||||
tenant_id = args[0]
|
||||
owner_user_id = args[1]
|
||||
limit = args[-1]
|
||||
rows = [
|
||||
{
|
||||
"calendar_event_id": row["calendar_event_id"],
|
||||
"lead_id": row["lead_id"],
|
||||
"title": row["title"],
|
||||
"description": row["description"],
|
||||
"start_at": row["start_at"],
|
||||
"end_at": row["end_at"],
|
||||
"all_day": row["all_day"],
|
||||
"status": row["status"],
|
||||
"reminder_minutes": row["reminder_minutes"],
|
||||
"created_by": row["created_by"],
|
||||
"location": row["location"],
|
||||
"metadata": row["metadata"],
|
||||
"created_at": row["created_at"].isoformat(),
|
||||
}
|
||||
for row in self.calendar_events.values()
|
||||
if row["tenant_id"] == tenant_id and row["owner_user_id"] == owner_user_id
|
||||
and row["status"] != "cancelled"
|
||||
]
|
||||
rows.sort(key=lambda item: item["start_at"])
|
||||
return rows[:limit]
|
||||
|
||||
if "FROM inventory_import_batches" in normalized:
|
||||
tenant_id, limit, offset = args
|
||||
rows = [
|
||||
{
|
||||
"batch_id": row["batch_id"],
|
||||
"source_type": row["source_type"],
|
||||
"submitted_by": row["submitted_by"],
|
||||
"status": row["status"],
|
||||
"total_rows": row["total_rows"],
|
||||
"accepted_rows": row["accepted_rows"],
|
||||
"rejected_rows": row["rejected_rows"],
|
||||
"created_at": row["created_at"].isoformat(),
|
||||
"completed_at": row["completed_at"],
|
||||
}
|
||||
for row in self.import_batches.values()
|
||||
if row["tenant_id"] == tenant_id
|
||||
]
|
||||
rows.sort(key=lambda item: item["created_at"], reverse=True)
|
||||
return rows[offset : offset + limit]
|
||||
|
||||
if "FROM inventory_properties" in normalized:
|
||||
params = list(args)
|
||||
tenant_id = params[0]
|
||||
limit = params[-2]
|
||||
offset = params[-1]
|
||||
status_filter = None
|
||||
property_type = None
|
||||
if len(params) == 4:
|
||||
status_filter = params[1]
|
||||
if len(params) == 5:
|
||||
status_filter = params[1]
|
||||
property_type = params[2]
|
||||
rows = [
|
||||
{
|
||||
"property_id": row["property_id"],
|
||||
"project_name": row["project_name"],
|
||||
"developer_name": row["developer_name"],
|
||||
"property_type": row["property_type"],
|
||||
"location": row["location"],
|
||||
"price_bands": row["price_bands"],
|
||||
"unit_mix": row["unit_mix"],
|
||||
"status": row["status"],
|
||||
"ingested_at": row["ingested_at"],
|
||||
"created_at": row["created_at"].isoformat(),
|
||||
}
|
||||
for row in self.properties.values()
|
||||
if row["tenant_id"] == tenant_id
|
||||
and (status_filter is None or row["status"] == status_filter)
|
||||
and (property_type is None or row["property_type"] == property_type)
|
||||
]
|
||||
rows.sort(key=lambda item: item["created_at"], reverse=True)
|
||||
return rows[offset : offset + limit]
|
||||
|
||||
raise AssertionError(f"Unexpected fetch query: {query}")
|
||||
|
||||
async def fetchval(self, query: str, *args):
|
||||
normalized = " ".join(query.strip().split())
|
||||
|
||||
if normalized.startswith("SELECT COUNT(*) FROM edge_communication_events WHERE tenant_id = $1 AND lead_id = $2"):
|
||||
tenant_id, lead_id = args
|
||||
return sum(
|
||||
1
|
||||
for row in self.events.values()
|
||||
if row["tenant_id"] == tenant_id and row["lead_id"] == lead_id
|
||||
)
|
||||
|
||||
if normalized.startswith("SELECT COUNT(*) FROM inventory_import_batches WHERE tenant_id=$1"):
|
||||
tenant_id = args[0]
|
||||
return sum(1 for row in self.import_batches.values() if row["tenant_id"] == tenant_id)
|
||||
|
||||
if normalized.startswith("SELECT COUNT(*) FROM inventory_properties WHERE tenant_id = $1"):
|
||||
tenant_id = args[0]
|
||||
return sum(1 for row in self.properties.values() if row["tenant_id"] == tenant_id)
|
||||
|
||||
raise AssertionError(f"Unexpected fetchval query: {query}")
|
||||
|
||||
async def execute(self, query: str, *args):
|
||||
normalized = " ".join(query.strip().split())
|
||||
|
||||
if "UPDATE user_calendar_events SET status='cancelled'" in normalized:
|
||||
tenant_id, owner_user_id, calendar_event_id = args
|
||||
row = self.calendar_events.get(calendar_event_id)
|
||||
if not row or row["tenant_id"] != tenant_id or row["owner_user_id"] != owner_user_id:
|
||||
return "UPDATE 0"
|
||||
row["status"] = "cancelled"
|
||||
return "UPDATE 1"
|
||||
|
||||
if normalized.startswith("UPDATE user_calendar_events SET"):
|
||||
tenant_id = args[-3]
|
||||
owner_user_id = args[-2]
|
||||
calendar_event_id = args[-1]
|
||||
row = self.calendar_events.get(calendar_event_id)
|
||||
if not row or row["tenant_id"] != tenant_id or row["owner_user_id"] != owner_user_id:
|
||||
return "UPDATE 0"
|
||||
assignments = normalized.split(" SET ", 1)[1].split(" WHERE ", 1)[0].split(", ")
|
||||
for assignment, value in zip(assignments, args):
|
||||
column = assignment.split(" = ", 1)[0]
|
||||
if column not in {"tenant_id", "owner_user_id"}:
|
||||
row[column] = value
|
||||
return "UPDATE 1"
|
||||
|
||||
raise AssertionError(f"Unexpected execute query: {query}")
|
||||
|
||||
|
||||
class FakeSurfacePool:
|
||||
def __init__(self) -> None:
|
||||
self.conn = FakeSurfaceConn()
|
||||
|
||||
@asynccontextmanager
|
||||
async def acquire(self):
|
||||
yield self.conn
|
||||
|
||||
|
||||
def _build_shared_app(pool: FakeSurfacePool, current_user: dict[str, str]) -> TestClient:
|
||||
app = FastAPI()
|
||||
app.state.db_pool = pool
|
||||
app.include_router(mobile_edge_router, prefix="/api/mobile-edge")
|
||||
app.include_router(inventory_router, prefix="/api/inventory")
|
||||
app.dependency_overrides[get_current_user] = lambda: UserPrincipal(
|
||||
current_user["user_id"],
|
||||
current_user["role"],
|
||||
current_user["tenant_id"],
|
||||
)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_mobile_edge_event_routes_scope_by_tenant_id_instead_of_role() -> None:
|
||||
pool = FakeSurfacePool()
|
||||
tenant_a = _build_shared_app(
|
||||
pool,
|
||||
{"user_id": "user-a", "role": "ADMIN", "tenant_id": "tenant_alpha"},
|
||||
)
|
||||
tenant_b = _build_shared_app(
|
||||
pool,
|
||||
{"user_id": "user-b", "role": "ADMIN", "tenant_id": "tenant_beta"},
|
||||
)
|
||||
|
||||
create_response = tenant_a.post(
|
||||
"/api/mobile-edge/events",
|
||||
json={
|
||||
"lead_id": "lead-123",
|
||||
"channel": "whatsapp_message",
|
||||
"direction": "inbound",
|
||||
"capture_mode": "operator_note",
|
||||
"consent_state": "granted",
|
||||
"summary": "Client asked for a marina brochure.",
|
||||
},
|
||||
)
|
||||
assert create_response.status_code == 201
|
||||
|
||||
tenant_a_events = tenant_a.get("/api/mobile-edge/events", params={"lead_id": "lead-123", "limit": 10})
|
||||
assert tenant_a_events.status_code == 200
|
||||
assert tenant_a_events.json()["total"] == 1
|
||||
|
||||
tenant_b_events = tenant_b.get("/api/mobile-edge/events", params={"lead_id": "lead-123", "limit": 10})
|
||||
assert tenant_b_events.status_code == 200
|
||||
assert tenant_b_events.json()["total"] == 0
|
||||
assert tenant_b_events.json()["events"] == []
|
||||
|
||||
|
||||
def test_mobile_edge_calendar_routes_scope_by_tenant_id_instead_of_role() -> None:
|
||||
pool = FakeSurfacePool()
|
||||
tenant_a = _build_shared_app(
|
||||
pool,
|
||||
{"user_id": "user-a", "role": "ADMIN", "tenant_id": "tenant_alpha"},
|
||||
)
|
||||
tenant_b = _build_shared_app(
|
||||
pool,
|
||||
{"user_id": "user-b", "role": "ADMIN", "tenant_id": "tenant_beta"},
|
||||
)
|
||||
|
||||
create_response = tenant_a.post(
|
||||
"/api/mobile-edge/calendar",
|
||||
json={
|
||||
"lead_id": "lead-123",
|
||||
"title": "Private site visit",
|
||||
"description": "Walkthrough with the lead",
|
||||
"start_at": "2026-04-23T10:00:00Z",
|
||||
"end_at": "2026-04-23T11:00:00Z",
|
||||
"all_day": False,
|
||||
"status": "tentative",
|
||||
"reminder_minutes": [15],
|
||||
"location": "Dubai Marina",
|
||||
"metadata": {"source": "ipad"},
|
||||
},
|
||||
)
|
||||
assert create_response.status_code == 201
|
||||
created_payload = create_response.json()
|
||||
assert created_payload["status"] == "ok"
|
||||
assert created_payload["event"]["title"] == "Private site visit"
|
||||
assert created_payload["event"]["location"] == "Dubai Marina"
|
||||
assert created_payload["event"]["status"] == "tentative"
|
||||
assert created_payload["event"]["reminder_minutes"] == [15]
|
||||
|
||||
tenant_a_calendar = tenant_a.get("/api/mobile-edge/calendar")
|
||||
assert tenant_a_calendar.status_code == 200
|
||||
assert len(tenant_a_calendar.json()["events"]) == 1
|
||||
|
||||
event_id = created_payload["event"]["calendar_event_id"]
|
||||
update_response = tenant_a.patch(
|
||||
f"/api/mobile-edge/calendar/{event_id}",
|
||||
json={"status": "confirmed"},
|
||||
)
|
||||
assert update_response.status_code == 200
|
||||
tenant_a_calendar = tenant_a.get("/api/mobile-edge/calendar")
|
||||
assert tenant_a_calendar.json()["events"][0]["status"] == "confirmed"
|
||||
|
||||
cancel_response = tenant_a.delete(f"/api/mobile-edge/calendar/{event_id}")
|
||||
assert cancel_response.status_code == 200
|
||||
tenant_a_calendar = tenant_a.get("/api/mobile-edge/calendar")
|
||||
assert tenant_a_calendar.json()["events"] == []
|
||||
|
||||
tenant_b_calendar = tenant_b.get("/api/mobile-edge/calendar")
|
||||
assert tenant_b_calendar.status_code == 200
|
||||
assert tenant_b_calendar.json()["events"] == []
|
||||
|
||||
|
||||
def test_inventory_property_routes_scope_by_tenant_id_instead_of_role() -> None:
|
||||
pool = FakeSurfacePool()
|
||||
tenant_a = _build_shared_app(
|
||||
pool,
|
||||
{"user_id": "user-a", "role": "ADMIN", "tenant_id": "tenant_alpha"},
|
||||
)
|
||||
tenant_b = _build_shared_app(
|
||||
pool,
|
||||
{"user_id": "user-b", "role": "ADMIN", "tenant_id": "tenant_beta"},
|
||||
)
|
||||
|
||||
create_response = tenant_a.post(
|
||||
"/api/inventory/properties",
|
||||
json={
|
||||
"project_name": "Marina One",
|
||||
"developer_name": "Desi Neuron Estates",
|
||||
"location": {"city": "Dubai", "district": "Marina"},
|
||||
"property_type": "apartment",
|
||||
"price_bands": [{"label": "from", "amount": 2400000}],
|
||||
"unit_mix": [{"type": "2BR", "count": 18}],
|
||||
"amenities": ["pool", "gym"],
|
||||
"status": "active",
|
||||
"validation_state": {"validated": True},
|
||||
},
|
||||
)
|
||||
assert create_response.status_code == 201
|
||||
|
||||
tenant_a_properties = tenant_a.get("/api/inventory/properties", params={"limit": 20})
|
||||
assert tenant_a_properties.status_code == 200
|
||||
assert tenant_a_properties.json()["total"] == 1
|
||||
|
||||
tenant_b_properties = tenant_b.get("/api/inventory/properties", params={"limit": 20})
|
||||
assert tenant_b_properties.status_code == 200
|
||||
assert tenant_b_properties.json()["total"] == 0
|
||||
assert tenant_b_properties.json()["properties"] == []
|
||||
|
||||
|
||||
def test_inventory_import_batch_routes_scope_by_tenant_id_instead_of_role() -> None:
|
||||
pool = FakeSurfacePool()
|
||||
tenant_a = _build_shared_app(
|
||||
pool,
|
||||
{"user_id": "user-a", "role": "ADMIN", "tenant_id": "tenant_alpha"},
|
||||
)
|
||||
tenant_b = _build_shared_app(
|
||||
pool,
|
||||
{"user_id": "user-b", "role": "ADMIN", "tenant_id": "tenant_beta"},
|
||||
)
|
||||
|
||||
create_response = tenant_a.post(
|
||||
"/api/inventory/import-batches",
|
||||
json={
|
||||
"source_type": "csv",
|
||||
"source_file_ref": "s3://velocity/imports/marina.csv",
|
||||
"total_rows": 24,
|
||||
},
|
||||
)
|
||||
assert create_response.status_code == 201
|
||||
|
||||
tenant_a_batches = tenant_a.get("/api/inventory/import-batches", params={"limit": 20})
|
||||
assert tenant_a_batches.status_code == 200
|
||||
assert tenant_a_batches.json()["total"] == 1
|
||||
|
||||
tenant_b_batches = tenant_b.get("/api/inventory/import-batches", params={"limit": 20})
|
||||
assert tenant_b_batches.status_code == 200
|
||||
assert tenant_b_batches.json()["total"] == 0
|
||||
assert tenant_b_batches.json()["batches"] == []
|
||||
Reference in New Issue
Block a user