feat: add CV view analytics and PDF rendering for public share links

Agent-Logs-Url: https://github.com/velocitatem/cvfs/sessions/fb35fb9a-a89e-4df0-9584-109f7151509c

Co-authored-by: velocitatem <60182044+velocitatem@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-04-04 05:59:05 +00:00
committed by GitHub
parent b63417b8b3
commit 7435a0f1bf
10 changed files with 260 additions and 24 deletions

View File

@@ -1,20 +1,53 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
import hashlib
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import Response
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, get_db
from app.core.config import get_settings
from app.models import PublicAsset
from app.schemas import PublicAssetLookupResponse, PublicAssetResponse, PublishRequest
from app.models import CvDocument, CvVersion, PublicAsset, PublicAssetView
from app.schemas import (
PublicAssetAnalyticsResponse,
PublicAssetLookupResponse,
PublicAssetResponse,
PublishRequest,
)
from app.services.publication import publish_version
from app.services.storage import storage_client
from dlib.auth import AuthenticatedUser
from dlib.cv import docx_bytes_to_pdf, generate_patched_docx
router = APIRouter(prefix="/public", tags=["public"])
async def _log_view(session: AsyncSession, asset: PublicAsset, request: Request) -> None:
ip = request.headers.get("x-forwarded-for", request.client.host if request.client else "")
ip_hash = hashlib.sha256(ip.split(",")[0].strip().encode()).hexdigest()[:16] if ip else None
view = PublicAssetView(
public_asset_id=asset.id,
viewed_at=datetime.utcnow(),
user_agent=request.headers.get("user-agent", "")[:512] or None,
ip_hash=ip_hash,
)
session.add(view)
await session.commit()
async def _get_public_asset(session: AsyncSession, slug: str) -> PublicAsset:
stmt = select(PublicAsset).where(PublicAsset.slug == slug, PublicAsset.is_public.is_(True))
result = await session.execute(stmt)
asset = result.scalars().one_or_none()
if not asset:
raise HTTPException(status_code=404, detail="Asset not found")
return asset
@router.post("/publish", response_model=PublicAssetResponse)
async def publish(
payload: PublishRequest,
@@ -33,21 +66,77 @@ async def publish(
return _response_from_asset(asset)
@router.get("/{slug}", response_model=PublicAssetLookupResponse)
async def get_public_asset(slug: str, session: AsyncSession = Depends(get_db)):
stmt = select(PublicAsset).where(
PublicAsset.slug == slug, PublicAsset.is_public.is_(True)
@router.get("/{slug}/analytics", response_model=PublicAssetAnalyticsResponse)
async def get_analytics(
slug: str,
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
asset = await _get_public_asset(session, slug)
if asset.version_id:
stmt = (
select(CvVersion)
.join(CvVersion.document)
.where(CvVersion.id == asset.version_id, CvDocument.owner_id == user.sub)
)
if not (await session.execute(stmt)).scalars().one_or_none():
raise HTTPException(status_code=403, detail="Not authorized")
else:
raise HTTPException(status_code=403, detail="Not authorized")
view_count = (
await session.execute(
select(func.count()).where(PublicAssetView.public_asset_id == asset.id)
)
).scalar() or 0
last_viewed_at = (
await session.execute(
select(PublicAssetView.viewed_at)
.where(PublicAssetView.public_asset_id == asset.id)
.order_by(PublicAssetView.viewed_at.desc())
.limit(1)
)
).scalar()
return PublicAssetAnalyticsResponse(
slug=slug, view_count=view_count, last_viewed_at=last_viewed_at
)
result = await session.execute(stmt)
asset = result.scalars().one_or_none()
if not asset:
raise HTTPException(status_code=404, detail="Asset not found")
@router.get("/{slug}/pdf")
async def get_public_pdf(slug: str, request: Request, session: AsyncSession = Depends(get_db)):
asset = await _get_public_asset(session, slug)
await _log_view(session, asset, request)
version: CvVersion | None = None
if asset.version_id:
stmt = select(CvVersion).where(CvVersion.id == asset.version_id)
version = (await session.execute(stmt)).scalars().one_or_none()
docx_bytes = storage_client.download_bytes(key=asset.artifact_key)
patched = generate_patched_docx(docx_bytes, (version.structured_blocks or []) if version else [])
pdf_bytes = docx_bytes_to_pdf(patched)
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="{slug}.pdf"'},
)
@router.get("/{slug}", response_model=PublicAssetLookupResponse)
async def get_public_asset(slug: str, request: Request, session: AsyncSession = Depends(get_db)):
asset = await _get_public_asset(session, slug)
await _log_view(session, asset, request)
return PublicAssetLookupResponse(asset=_response_from_asset(asset))
def _response_from_asset(asset: PublicAsset) -> PublicAssetResponse:
settings = get_settings()
url = f"{settings.public_base_url.rstrip('/')}/cv/{asset.slug}"
base = settings.public_base_url.rstrip("/")
url = f"{base}/cv/{asset.slug}"
return PublicAssetResponse(
id=asset.id,
slug=asset.slug,

View File

@@ -4,6 +4,7 @@ from .cv import (
CvPatch,
CvVersion,
PublicAsset,
PublicAssetView,
Specialization,
Submission,
SubmissionStatus,
@@ -17,5 +18,6 @@ __all__ = [
"Submission",
"SubmissionStatus",
"PublicAsset",
"PublicAssetView",
"AiSuggestion",
]

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import enum
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB
@@ -138,6 +139,24 @@ class PublicAsset(Base, IdentifierMixin, TimestampMixin):
"Submission", back_populates="public_asset"
)
version: Mapped[CvVersion | None] = relationship("CvVersion")
views: Mapped[list["PublicAssetView"]] = relationship(
"PublicAssetView", back_populates="public_asset", cascade="all, delete-orphan", passive_deletes=True,
)
class PublicAssetView(Base, IdentifierMixin):
__tablename__ = "public_asset_views"
public_asset_id: Mapped[str] = mapped_column(
ForeignKey("public_assets.id", ondelete="CASCADE"), index=True
)
viewed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.utcnow, index=True
)
user_agent: Mapped[str | None] = mapped_column(String(512), nullable=True)
ip_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
public_asset: Mapped[PublicAsset] = relationship("PublicAsset", back_populates="views")
class AiSuggestion(Base, IdentifierMixin, TimestampMixin):

View File

@@ -4,6 +4,7 @@ from .cv import (
DocumentCreateResult,
DocumentListResponse,
DocumentResponse,
PublicAssetAnalyticsResponse,
PublicAssetLookupResponse,
PublicAssetResponse,
PublishRequest,
@@ -28,4 +29,5 @@ __all__ = [
"PublishRequest",
"PublicAssetResponse",
"PublicAssetLookupResponse",
"PublicAssetAnalyticsResponse",
]

View File

@@ -125,5 +125,11 @@ class PublicAssetLookupResponse(BaseModel):
asset: PublicAssetResponse
class PublicAssetAnalyticsResponse(BaseModel):
slug: str
view_count: int
last_viewed_at: datetime | None = None
class SuggestionUpdateRequest(BaseModel):
accepted: bool

View File

@@ -7,7 +7,9 @@ import Link from 'next/link';
import {
createBranch, createSubmission, deleteDocument, deleteVersion,
Document, downloadVersionUrl,
fetchDocuments, fetchSubmissions, publishVersion, requestAiSuggestions,
fetchDocuments, fetchSubmissions, fetchPublicAssetAnalytics, getPublicPdfUrl,
publishVersion, PublicAsset, PublicAssetAnalytics,
requestAiSuggestions,
Submission, StructuredBlock, Suggestion, updateSuggestion, uploadDocument, Version,
} from '@/libs/api';
@@ -166,7 +168,7 @@ function SubmissionModal({ version, onClose, onDone }: { version: Version; onClo
);
}
function PublishModal({ version, onClose, onDone }: { version: Version; onClose: () => void; onDone: (url: string) => void }) {
function PublishModal({ version, onClose, onDone }: { version: Version; onClose: () => void; onDone: (asset: PublicAsset) => void }) {
const [slug, setSlug] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
@@ -175,7 +177,7 @@ function PublishModal({ version, onClose, onDone }: { version: Version; onClose:
setLoading(true); setError('');
try {
const asset = await publishVersion(version.id, null, slug.trim() || null);
onDone(asset.url ?? asset.slug);
onDone(asset);
} catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed'); setLoading(false); }
};
@@ -478,7 +480,8 @@ export default function Dashboard() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [modal, setModal] = useState<Modal>(null);
const [publishedUrl, setPublishedUrl] = useState<string | null>(null);
const [publishedAsset, setPublishedAsset] = useState<PublicAsset | null>(null);
const [publishedAnalytics, setPublishedAnalytics] = useState<PublicAssetAnalytics | null>(null);
const [activeTab, setActiveTab] = useState<Tab>('content');
const [submissions, setSubmissions] = useState<Submission[]>([]);
const [subsLoading, setSubsLoading] = useState(false);
@@ -762,14 +765,35 @@ export default function Dashboard() {
</div>
</div>
{publishedUrl && (
{publishedAsset && (
<div style={{
padding: '8px 12px', background: '#f0fdf4', border: '1px solid #bbf7d0',
borderRadius: 5, marginBottom: 12, fontSize: 13, display: 'flex', gap: 8, alignItems: 'center',
padding: '10px 12px', background: '#f0fdf4', border: '1px solid #bbf7d0',
borderRadius: 5, marginBottom: 12, fontSize: 13, display: 'flex', flexDirection: 'column', gap: 6,
}}>
<span style={{ color: '#166534' }}>Published:</span>
<a href={publishedUrl} target="_blank" rel="noreferrer" style={{ color: '#166534', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{publishedUrl}</a>
<button onClick={() => setPublishedUrl(null)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#166534', fontSize: 16, lineHeight: 1 }}>×</button>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<span style={{ color: '#166534', fontWeight: 500 }}>Published</span>
{publishedAnalytics !== null && (
<span style={{ color: '#166534', fontSize: 11, background: '#dcfce7', padding: '1px 7px', borderRadius: 10 }}>
{publishedAnalytics.view_count} view{publishedAnalytics.view_count !== 1 ? 's' : ''}
</span>
)}
<button
onClick={() => fetchPublicAssetAnalytics(publishedAsset.slug).then(setPublishedAnalytics).catch(() => null)}
style={{ background: 'none', border: '1px solid #bbf7d0', cursor: 'pointer', color: '#15803d', fontSize: 11, padding: '1px 6px', borderRadius: 4 }}
>
stats
</button>
<button onClick={() => { setPublishedAsset(null); setPublishedAnalytics(null); }} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#166534', fontSize: 16, lineHeight: 1, marginLeft: 'auto' }}>×</button>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<a href={publishedAsset.url ?? '#'} target="_blank" rel="noreferrer" style={{ color: '#166534', fontSize: 12, textDecoration: 'underline' }}>
Share link
</a>
<span style={{ color: '#bbf7d0' }}>|</span>
<a href={getPublicPdfUrl(publishedAsset.slug)} target="_blank" rel="noreferrer" style={{ color: '#166534', fontSize: 12, textDecoration: 'underline' }}>
View PDF
</a>
</div>
</div>
)}
@@ -861,7 +885,7 @@ export default function Dashboard() {
<PublishModal
version={selectedVersion}
onClose={() => setModal(null)}
onDone={url => { setPublishedUrl(url); setModal(null); }}
onDone={asset => { setPublishedAsset(asset); setPublishedAnalytics(null); setModal(null); }}
/>
)}
</div>

View File

@@ -73,6 +73,12 @@ export type PublicAsset = {
created_at: string;
};
export type PublicAssetAnalytics = {
slug: string;
view_count: number;
last_viewed_at?: string | null;
};
// reads OIDC bearer token from client-readable cookie (set by /api/auth/callback)
function getAuthHeader(): Record<string, string> {
if (typeof document === 'undefined') return {};
@@ -178,6 +184,12 @@ export async function publishVersion(
});
}
export const getPublicPdfUrl = (slug: string): string =>
`${API}/api/v1/public/${encodeURIComponent(slug)}/pdf`;
export const fetchPublicAssetAnalytics = (slug: string): Promise<PublicAssetAnalytics> =>
req<PublicAssetAnalytics>(`/api/v1/public/${encodeURIComponent(slug)}/analytics`);
export async function deleteDocument(documentId: string): Promise<void> {
const res = await fetch(`${API}/api/v1/documents/${documentId}`, {
method: 'DELETE',