forked from sagnik/Project_Velocity
feat: Ipad app features and Dream Weaver for Velocity WebOS
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),
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user