Files
Project_Velocity/backend/routers/vault.py
2026-04-12 02:02:58 +05:30

191 lines
6.3 KiB
Python

"""
backend/routers/vault.py — Velocity Vault (Trackable Link) Router
Endpoints:
POST /api/vault/generate-link → Generate a trackable URL for a shared asset
GET /vault/{tracking_hash} → Public link accessed by the prospect;
logs the open, fires WS_ASSET_OPENED
SRS Reference: Section 3C — Velocity Link Generation
"""
from __future__ import annotations
import os
import secrets
from datetime import datetime, timezone
from typing import Optional
import asyncpg
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse, JSONResponse
from pydantic import BaseModel, UUID4
from backend.auth.dependencies import UserPrincipal, require_role
from backend.db.pool import get_pool
router = APIRouter()
# ── Pydantic models ───────────────────────────────────────────────────────────
class GenerateLinkRequest(BaseModel):
lead_id: str
asset_name: str
asset_type: str # 'pdf' | 'image' | 'video'
storage_path: str # relative to /opt/dlami/nvme/assets/
class GenerateLinkResponse(BaseModel):
tracking_hash: str
vault_url: str
asset_id: str
# ── Helper: WebSocket broadcast ───────────────────────────────────────────────
async def _broadcast_vault_opened(
request: Request,
lead_id: str,
lead_name: str,
asset_name: str,
tracking_hash: str,
ip: Optional[str],
) -> None:
"""Fires WS_ASSET_OPENED to all broker WebSocket clients watching this lead."""
broadcast = getattr(request.app.state, "broadcast_sentinel_event", None)
if broadcast:
await broadcast({
"type": "WS_ASSET_OPENED",
"data": {
"lead_id": lead_id,
"lead_name": lead_name,
"asset_name": asset_name,
"tracking_hash": tracking_hash,
"opened_at": datetime.now(timezone.utc).isoformat(),
"ip": ip,
},
})
# ── POST /api/vault/generate-link ─────────────────────────────────────────────
@router.post(
"/generate-link",
response_model=GenerateLinkResponse,
status_code=status.HTTP_201_CREATED,
summary="Generate a trackable Velocity Link for a document share",
)
async def generate_link(
body: GenerateLinkRequest,
request: Request,
pool: asyncpg.Pool = Depends(get_pool),
user: UserPrincipal = Depends(require_role("SENIOR_BROKER")),
) -> GenerateLinkResponse:
"""
Creates a cryptographically unique URL for every document share instance.
When the prospect opens the URL, FastAPI logs the event and fires a
real-time WebSocket notification to the broker's Active Notification Center.
"""
tracking_hash = secrets.token_hex(32) # 64 character hex string
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO velocity_vault_assets
(asset_name, asset_type, storage_path, tracking_hash, lead_id, created_by)
VALUES ($1, $2, $3, $4, $5::uuid, $6::uuid)
RETURNING id::text
""",
body.asset_name,
body.asset_type,
body.storage_path,
tracking_hash,
body.lead_id,
user.user_id,
)
base_url = os.getenv("VELOCITY_API_BASE_URL", "http://localhost:8000")
vault_url = f"{base_url}/vault/{tracking_hash}"
return GenerateLinkResponse(
tracking_hash=tracking_hash,
vault_url=vault_url,
asset_id=row["id"],
)
# ── GET /vault/{tracking_hash} ────────────────────────────────────────────────
@router.get(
"/{tracking_hash}",
summary="Public Velocity Link endpoint — accessed by the prospect",
include_in_schema=False,
)
async def open_vault_link(
tracking_hash: str,
request: Request,
pool: asyncpg.Pool = Depends(get_pool),
) -> RedirectResponse:
"""
No auth required — this URL is shared with the prospect externally.
On access:
1. Appends NOW() to velocity_vault_assets.opened_at
2. Writes a WS_ASSET_OPENED entry to omnichannel_logs
3. Broadcasts the event to all connected broker WebSocket clients
4. Redirects the prospect to the actual asset file
"""
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
UPDATE velocity_vault_assets
SET opened_at = array_append(opened_at, NOW())
WHERE tracking_hash = $1
RETURNING id::text, lead_id::text, asset_name, storage_path
""",
tracking_hash,
)
if row is None:
raise HTTPException(status_code=404, detail="Link not found or expired.")
lead_id = row["lead_id"]
asset_name = row["asset_name"]
# Fetch lead name for the notification body
lead_row = await conn.fetchrow(
"SELECT name FROM leads_intelligence WHERE id = $1::uuid",
lead_id,
)
lead_name = lead_row["name"] if lead_row else "Unknown Lead"
# Write to omnichannel_logs
ip = request.client.host if request.client else None
await conn.execute(
"""
INSERT INTO omnichannel_logs (event_type, lead_id, payload)
VALUES ('WS_ASSET_OPENED', $1::uuid, $2::jsonb)
""",
lead_id,
{
"tracking_hash": tracking_hash,
"asset_name": asset_name,
"ip": ip,
"user_agent": request.headers.get("user-agent", ""),
},
)
# Fire real-time WebSocket broadcast to all brokers
await _broadcast_vault_opened(
request=request,
lead_id=lead_id,
lead_name=lead_name,
asset_name=asset_name,
tracking_hash=tracking_hash,
ip=ip,
)
# Redirect to the static asset file served by FastAPI
asset_url = f"/assets/{row['storage_path']}"
return RedirectResponse(url=asset_url, status_code=302)