""" 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)