191 lines
6.3 KiB
Python
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)
|